diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42248a0..a4dcc56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,20 +75,23 @@ execution. The intended crate roles are: - `feder-vocab`: Type-safe representations of Activity Vocabulary objects, such as actors, notes, and activities. - - `feder-core`: The portable ActivityPub protocol engine, responsible for - protocol decisions and state transitions. - - Runtime crates: Platform-specific execution layers for networking, storage, - clocks, timers, async runtimes, and operating system or hardware - integration. + - `feder-core`: Portable ActivityPub protocol decisions and capability traits. + It derives transient outcomes from facts supplied by its caller and does + not retain protocol state. + - `feder-server`: The standard operating system runtime for HTTP networking, + SQLite storage, actor resolution, request verification, and activity + delivery. + - Future runtime crates: Platform-specific implementations for other async + runtimes, operating systems, or hardware environments. When contributing to `feder-core`, avoid adding direct dependencies on HTTP clients or servers, databases, filesystems, async runtimes, system clocks, or platform-specific crates. Runtime crates may use those dependencies when appropriate, but those choices should not leak into the portable core. -Core behaviour should generally be tested by feeding an input into the core and -asserting the returned actions. Core tests should not require real networking, -storage, or async execution. +Core behaviour should generally be tested by feeding stored facts and protocol +input into a core function and asserting the returned transient outcome. Core +tests should not require real networking, storage, or async execution. ### Git pre-commit hook diff --git a/Cargo.lock b/Cargo.lock index 9a9f225..bc02422 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,35 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -69,12 +98,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -94,6 +144,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -103,6 +155,131 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -119,23 +296,32 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" name = "feder-core" version = "0.1.0" dependencies = [ + "base64", "feder-vocab", + "rsa", + "zeroize", ] [[package]] -name = "feder-runtime-server" +name = "feder-server" version = "0.1.0" dependencies = [ "axum", "feder-core", "feder-vocab", + "httpdate", + "ipnet", + "mime", "percent-encoding", + "rand_core 0.6.4", + "reqwest", "rusqlite", "serde", "serde_json", "thiserror", "tokio", "tower", + "url", ] [[package]] @@ -168,6 +354,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -201,6 +393,43 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -291,6 +520,22 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -299,15 +544,132 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "iri-string" version = "0.7.12" @@ -323,6 +685,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -330,6 +751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -338,6 +760,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -345,6 +770,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -356,12 +787,24 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -397,7 +840,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -406,26 +849,107 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "num-iter" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] [[package]] name = "pkg-config" @@ -433,6 +957,24 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -442,6 +984,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -451,6 +1050,67 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -468,6 +1128,76 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -493,6 +1223,96 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -505,6 +1325,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -532,7 +1399,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -571,6 +1438,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -586,12 +1464,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single-user-server" version = "0.1.0" dependencies = [ "axum", - "feder-runtime-server", + "feder-core", + "feder-server", + "feder-vocab", + "rand_core 0.6.4", "tokio", "tracing", "tracing-subscriber", @@ -616,7 +1523,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", ] [[package]] @@ -631,6 +1554,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -642,30 +1577,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -677,18 +1637,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -699,7 +1685,17 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -718,6 +1714,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -750,7 +1764,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -792,12 +1806,48 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -810,6 +1860,31 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -829,6 +1904,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -848,7 +1933,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -861,12 +1946,59 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -876,6 +2008,179 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3e2de24..33ce3d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = [ "crates/feder-core", "crates/feder-vocab", - "crates/feder-runtime-server", + "crates/feder-server", "examples/single-user-server", ] resolver = "3" @@ -18,13 +18,18 @@ repository = "https://github.com/fedify-dev/feder" [workspace.dependencies] # Keep workspace crate versions in sync with [workspace.package].version. # Use `mise run bump-execute ` instead of editing these by hand. +base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } feder-core = { version = "0.1.0", path = "crates/feder-core" } -feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } +feder-server = { version = "0.1.0", path = "crates/feder-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +rand_chacha = { version = "0.3.1", default-features = false } +rand_core = { version = "0.6.4", features = ["getrandom"] } +rsa = { version = "0.9.10", default-features = false, features = ["pem", "sha2", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" tower = { version = "0.5", features = ["util"]} +zeroize = { version = "1.9.0", default-features = false, features = ["alloc"] } [workspace.lints.rust] warnings = "deny" diff --git a/README.md b/README.md index 5e36b5a..8aa2929 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ software so different parts can run on machines with very different resources. Approach -------- -Feder separates ActivityPub protocol logic from platform execution. The core -should contain deterministic protocol decisions. Runtimes provide -platform-specific pieces such as networking, storage, clocks, scheduling, and -execution. +Feder separates ActivityPub protocol decisions from platform execution. +`feder-core` derives transient protocol outcomes from application-provided +facts without retaining state. `feder-server` supplies a standard operating +system runtime with HTTP networking, SQLite storage, actor resolution, request +verification, and activity delivery. The first target is a Linux proof of concept for a small single-user ActivityPub server. Future runtimes may explore more constrained environments. diff --git a/crates/feder-core/Cargo.toml b/crates/feder-core/Cargo.toml index 40c5a8f..4b17412 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feder-core" -description = "Portable ActivityPub core logic for Feder." +description = "Portable ActivityPub protocol decisions and capability traits for Feder." version.workspace = true edition.workspace = true authors.workspace = true @@ -8,8 +8,19 @@ license.workspace = true homepage.workspace = true repository.workspace = true +[features] +http-signatures = ["dep:base64", "dep:rsa", "dep:zeroize"] + [dependencies] +base64 = { workspace = true, optional = true } feder-vocab.workspace = true +rsa = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } [lints] workspace = true + +[[test]] +name = "key" +path = "tests/key.rs" +required-features = ["http-signatures"] diff --git a/crates/feder-core/README.md b/crates/feder-core/README.md deleted file mode 100644 index c3d7005..0000000 --- a/crates/feder-core/README.md +++ /dev/null @@ -1,17 +0,0 @@ -Feder Core -========== - -Pure ActivityPub decision logic for Feder. - -This crate does not perform HTTP, database, filesystem, clock, or random ID -operations. A runtime provides the state and context needed for one decision, -then applies the returned decision itself. - - -Core decisions --------------- - -~~~~ text -stored state + policy + context + ActivityPub input - -> state changes + effects -~~~~ diff --git a/crates/feder-core/src/follow.rs b/crates/feder-core/src/follow.rs new file mode 100644 index 0000000..d0169a6 --- /dev/null +++ b/crates/feder-core/src/follow.rs @@ -0,0 +1,183 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::fmt; + +use feder_vocab::{Accept, Actor, Follow, Iri, Reference}; + +/// The transient result of accepting one valid Follow activity. +/// +/// Core does not retain this value or write it to storage. A runtime persists +/// `follower` and `following`, then delivers `accept` to `recipient_inbox`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FollowOutcome { + pub follower: Actor, + pub following: Iri, + pub accept: Accept, + pub recipient_inbox: Iri, +} + +/// A pending outbound Follow relationship for application-owned storage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingFollow { + pub local_actor: Iri, + pub remote_actor: Actor, + pub follow_activity: Iri, +} + +/// The transient result of creating one outbound Follow activity. +/// +/// Core retains neither the activity nor its pending relationship. A runtime +/// persists `relationship` before delivering `activity` to the remote actor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateFollowOutcome { + pub relationship: PendingFollow, + pub activity: Follow, +} + +#[must_use] +pub fn create_follow( + local_actor: &Actor, + remote_actor: &Actor, + follow_id: Iri, +) -> CreateFollowOutcome { + CreateFollowOutcome { + relationship: PendingFollow { + local_actor: local_actor.id.clone(), + remote_actor: remote_actor.clone(), + follow_activity: follow_id.clone(), + }, + activity: Follow::new( + follow_id, + Reference::id(local_actor.id.clone()), + Reference::id(remote_actor.id.clone()), + ), + } +} + +pub fn receive_accept_follow( + local_actor: &Actor, + remote_actor: &Actor, + pending: &PendingFollow, + accept: Accept, +) -> Result<(), AcceptFollowError> { + if pending.local_actor != local_actor.id { + return Err(AcceptFollowError::WrongLocalActor); + } + if pending.remote_actor.id != remote_actor.id || reference_id(&accept.actor) != &remote_actor.id + { + return Err(AcceptFollowError::WrongActor); + } + if follow_reference_id(&accept.object) != &pending.follow_activity { + return Err(AcceptFollowError::WrongFollow); + } + if let Reference::Object(follow) = &accept.object { + if reference_id(&follow.actor) != &local_actor.id { + return Err(AcceptFollowError::WrongFollowActor); + } + if reference_id(&follow.object) != &remote_actor.id { + return Err(AcceptFollowError::WrongFollowObject); + } + } + + Ok(()) +} + +pub fn receive_follow( + local_actor: &Actor, + remote_actor: &Actor, + mut follow: Follow, + accept_id: Iri, +) -> Result { + if reference_id(&follow.object) != &local_actor.id { + return Err(FollowError::WrongObject); + } + if reference_id(&follow.actor) != &remote_actor.id { + return Err(FollowError::WrongActor); + } + + follow.actor = Reference::object(remote_actor.clone()); + + Ok(FollowOutcome { + follower: remote_actor.clone(), + following: local_actor.id.clone(), + accept: Accept::new( + accept_id, + Reference::id(local_actor.id.clone()), + Reference::object(follow), + ), + recipient_inbox: remote_actor.inbox.clone(), + }) +} + +fn reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(actor) => &actor.id, + } +} + +fn follow_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(follow) => &follow.id, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AcceptFollowError { + WrongActor, + WrongFollow, + WrongFollowActor, + WrongFollowObject, + WrongLocalActor, +} + +impl fmt::Display for AcceptFollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongActor => formatter.write_str("Accept actor does not match remote actor"), + Self::WrongFollow => formatter.write_str("Accept does not reference pending Follow"), + Self::WrongFollowActor => { + formatter.write_str("accepted Follow actor does not match local actor") + } + Self::WrongFollowObject => { + formatter.write_str("accepted Follow does not target remote actor") + } + Self::WrongLocalActor => { + formatter.write_str("pending Follow does not belong to local actor") + } + } + } +} + +impl core::error::Error for AcceptFollowError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FollowError { + WrongActor, + WrongObject, +} + +impl fmt::Display for FollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongActor => formatter.write_str("Follow actor does not match remote actor"), + Self::WrongObject => formatter.write_str("Follow does not target the local actor"), + } + } +} + +impl core::error::Error for FollowError {} diff --git a/crates/feder-core/src/key.rs b/crates/feder-core/src/key.rs new file mode 100644 index 0000000..d03f0ed --- /dev/null +++ b/crates/feder-core/src/key.rs @@ -0,0 +1,239 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::{format, string::String, vec::Vec}; +use core::fmt; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs1v15::{Signature, SigningKey, VerifyingKey}, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, + rand_core::CryptoRngCore, + sha2::{Digest, Sha256}, + signature::{SignatureEncoding, Signer, Verifier}, +}; +use zeroize::Zeroizing; + +const ACTOR_RSA_BITS: usize = 4096; + +#[derive(Clone, Eq, PartialEq)] +pub struct ActorKeyPair { + private_key_pem: Zeroizing, + public_key_pem: String, +} + +impl ActorKeyPair { + pub fn from_pem(private_key_pem: String, public_key_pem: String) -> Result { + let private_key_pem = Zeroizing::new(private_key_pem); + let private_key = + RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(KeyError::InvalidPrivateKey)?; + let public_key = RsaPublicKey::from_public_key_pem(&public_key_pem) + .map_err(KeyError::InvalidPublicKey)?; + + if RsaPublicKey::from(&private_key) != public_key { + return Err(KeyError::MismatchedKeyPair); + } + + Ok(Self { + private_key_pem, + public_key_pem, + }) + } + + #[must_use] + pub fn private_key_pem(&self) -> &str { + &self.private_key_pem + } + + #[must_use] + pub fn public_key_pem(&self) -> &str { + &self.public_key_pem + } +} + +impl fmt::Debug for ActorKeyPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActorKeyPair") + .field("private_key_pem", &"[REDACTED]") + .field("public_key_pem", &self.public_key_pem) + .finish() + } +} + +/// Generates a 4096-bit RSA actor key pair for draft-Cavage HTTP signatures. +/// The caller must supply a cryptographically secure random number generator for the target runtime. +pub fn generate_actor_key_pair( + rng: &mut (impl CryptoRngCore + ?Sized), +) -> Result { + let private_key = RsaPrivateKey::new(rng, ACTOR_RSA_BITS).map_err(KeyError::Generation)?; + let public_key = RsaPublicKey::from(&private_key); + let private_key_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .map_err(KeyError::PrivateKeyEncoding)?; + let public_key_pem = public_key + .to_public_key_pem(LineEnding::LF) + .map_err(KeyError::PublicKeyEncoding)?; + + Ok(ActorKeyPair { + private_key_pem, + public_key_pem, + }) +} + +#[derive(Debug)] +pub enum KeyError { + Generation(rsa::Error), + PrivateKeyEncoding(rsa::pkcs8::Error), + PublicKeyEncoding(rsa::pkcs8::spki::Error), + InvalidPrivateKey(rsa::pkcs8::Error), + InvalidPublicKey(rsa::pkcs8::spki::Error), + MismatchedKeyPair, +} + +impl fmt::Display for KeyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Generation(_) => formatter.write_str("failed to generate RSA actor key"), + Self::PrivateKeyEncoding(_) => formatter.write_str("failed to encode RSA private key"), + Self::PublicKeyEncoding(_) => formatter.write_str("failed to encode RSA public key"), + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::MismatchedKeyPair => formatter.write_str("RSA actor keys do not match"), + } + } +} + +impl core::error::Error for KeyError {} + +/// Creates an RFC 3230 SHA-256 digest header. +#[must_use] +pub fn create_sha256_digest_header(body: &[u8]) -> String { + let digest = Sha256::digest(body); + format!("SHA-256={}", STANDARD.encode(digest)) +} + +/// Signs a prepared HTTP request using the draft-Cavage header format. +/// +/// Header names must be supplied in the order in which they should appear in +/// the signature's `headers` parameter. +pub fn sign_draft_cavage( + key_pair: &ActorKeyPair, + key_id: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> Result { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let private_key = RsaPrivateKey::from_pkcs8_pem(key_pair.private_key_pem()) + .map_err(HttpSignatureError::InvalidPrivateKey)?; + let signing_key = SigningKey::::new(private_key); + let signature = signing_key.sign(signature_base.as_bytes()).to_bytes(); + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + + Ok(format!( + "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"{}\"", + STANDARD.encode(signature) + )) +} + +/// Verifies a draft-Cavage RSA-SHA256 signature over a prepared request. +/// +/// `headers` must contain the signed HTTP headers in their declared order, +/// excluding the `(request-target)` pseudo-header. +pub fn verify_draft_cavage( + public_key_pem: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], + signature: &str, +) -> Result<(), HttpSignatureVerificationError> { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) + .map_err(HttpSignatureVerificationError::InvalidPublicKey)?; + let signature = STANDARD + .decode(signature) + .map_err(HttpSignatureVerificationError::InvalidSignatureEncoding)?; + let signature = Signature::try_from(signature.as_slice()) + .map_err(HttpSignatureVerificationError::InvalidSignature)?; + let verifying_key = VerifyingKey::::new(public_key); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .map_err(HttpSignatureVerificationError::Verification) +} + +fn draft_cavage_signature_base( + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> String { + let mut lines = Vec::with_capacity(headers.len() + 1); + lines.push(format!( + "(request-target): {} {request_target}", + method.to_ascii_lowercase() + )); + lines.extend( + headers + .iter() + .map(|(name, value)| format!("{}: {}", name.to_ascii_lowercase(), value.trim())), + ); + lines.join("\n") +} + +/// Errors produced while creating an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureError { + InvalidPrivateKey(rsa::pkcs8::Error), +} + +impl fmt::Display for HttpSignatureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + } + } +} + +impl core::error::Error for HttpSignatureError {} + +/// Errors produced while verifying an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureVerificationError { + InvalidPublicKey(rsa::pkcs8::spki::Error), + InvalidSignatureEncoding(base64::DecodeError), + InvalidSignature(rsa::signature::Error), + Verification(rsa::signature::Error), +} + +impl fmt::Display for HttpSignatureVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::InvalidSignatureEncoding(_) => { + formatter.write_str("invalid base64 signature encoding") + } + Self::InvalidSignature(_) => formatter.write_str("invalid RSA signature"), + Self::Verification(_) => formatter.write_str("HTTP signature verification failed"), + } + } +} + +impl core::error::Error for HttpSignatureVerificationError {} diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 5cf923a..219e0ba 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -13,223 +13,29 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Portable ActivityPub core logic for Feder. +//! Portable ActivityPub protocol decisions and capability traits for Feder. +//! +//! This crate is independent of networking, persistence, operating-system +//! services, and async executors. Runtimes supply stored facts and execute the +//! transient outcomes returned by its protocol functions. #![no_std] extern crate alloc; -use alloc::vec::Vec; - pub use feder_vocab as vocab; +use feder_vocab::{Actor, Iri}; -/// Portable core decision logic. -#[derive(Debug, Default)] -pub struct FederCore; - -impl FederCore { - #[must_use] - pub fn new() -> Self { - Self - } - - pub fn decide_received_follow( - &self, - follow: vocab::Follow, - state: ReceivedFollowState, - policy: FollowPolicyDecision, - context: DecisionContext, - ) -> Result { - if state.already_processed { - return Ok(Decision::none()); - } - - let Some(following) = reference_id(&follow.object) else { - return Ok(Decision::none()); - }; - - if following != &context.local_actor { - return Ok(Decision::none()); - } - - let Some(follower) = reference_id(&follow.actor).cloned() else { - return Ok(Decision::none()); - }; - - match policy { - FollowPolicyDecision::Reject | FollowPolicyDecision::RequireManualApproval => { - return Ok(Decision::none()); - } - FollowPolicyDecision::Accept => {} - } - - let remote_actor = state.remote_actor.ok_or(CoreError::MissingRemoteActor)?; - let inbox = remote_actor - .shared_inbox - .clone() - .or_else(|| remote_actor.inbox.clone()) - .ok_or(CoreError::MissingInbox)?; - - let accept = vocab::Accept::new( - context.accept_id, - vocab::Reference::id(context.local_actor.clone()), - vocab::Reference::object(follow.clone()), - ); - let accept = Activity::Accept(accept); - - let mut state_changes = Vec::from([StateChange::RecordProcessedActivity { - activity_id: follow.id.clone(), - }]); - - state_changes.push(StateChange::AddFollower { - local_actor: context.local_actor, - remote_actor: follower, - inbox: remote_actor.inbox, - shared_inbox: remote_actor.shared_inbox, - }); +pub mod follow; +#[cfg(feature = "http-signatures")] +pub mod key; +pub mod note; +pub mod storage; +pub mod undo; - state_changes.push(StateChange::StoreActivity { - activity: accept.clone(), - }); +pub trait ActorDispatcher { + type Error; - Ok(Decision { - state_changes, - effects: Vec::from([Effect::PlanDelivery(PlannedDelivery { - activity: accept, - inbox, - })]), - }) - } -} - -fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> -where - T: HasId, -{ - match reference { - vocab::Reference::Id(id) => Some(id), - vocab::Reference::Object(object) => Some(object.id()), - } -} - -trait HasId { - fn id(&self) -> &vocab::Iri; -} - -impl HasId for vocab::Actor { - fn id(&self) -> &vocab::Iri { - &self.id - } -} - -/// Runtime-provided stored state for deciding a received Follow. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReceivedFollowState { - pub already_processed: bool, - pub relationship: FollowRelationship, - pub remote_actor: Option, -} - -/// Current stored relationship between a remote actor and the local actor. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FollowRelationship { - NotFollowing, - Following, -} - -/// Runtime-known state for a remote actor referenced by an input. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RemoteActorState { - pub actor_id: vocab::Iri, - pub inbox: Option, - pub shared_inbox: Option, -} - -/// Application policy decision for a received Follow. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FollowPolicyDecision { - Accept, - Reject, - RequireManualApproval, -} - -/// Deterministic runtime-provided context for one core decision. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DecisionContext { - pub local_actor: vocab::Iri, - pub accept_id: vocab::Iri, -} - -/// Declarative result of a pure core decision. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Decision { - pub state_changes: Vec, - pub effects: Vec, -} - -impl Decision { - #[must_use] - pub fn none() -> Self { - Self::default() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.state_changes.is_empty() && self.effects.is_empty() - } -} - -/// Durable state change a runtime should apply transactionally. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum StateChange { - RecordProcessedActivity { - activity_id: vocab::Iri, - }, - AddFollower { - local_actor: vocab::Iri, - remote_actor: vocab::Iri, - inbox: Option, - shared_inbox: Option, - }, - StoreActivity { - activity: Activity, - }, - StoreObject { - object: Object, - }, -} - -/// External work a runtime should plan after durable state is committed. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Effect { - PlanDelivery(PlannedDelivery), -} - -/// Delivery work to persist for a later delivery worker. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PlannedDelivery { - pub activity: Activity, - pub inbox: vocab::Iri, -} - -/// Error raised while deciding protocol consequences. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum CoreError { - MissingRemoteActor, - MissingInbox, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Activity { - Accept(vocab::Accept), - CreateNote(vocab::Create), -} + fn get_actor(&self, identifier: &str) -> Result, Self::Error>; -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Object { - Note(vocab::Note), + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error>; } diff --git a/crates/feder-core/src/note.rs b/crates/feder-core/src/note.rs new file mode 100644 index 0000000..76e41ff --- /dev/null +++ b/crates/feder-core/src/note.rs @@ -0,0 +1,107 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::{string::String, vec::Vec}; + +use feder_vocab::{Actor, Create, Iri, Note, Reference, References}; + +pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; + +/// A transient delivery intent derived from a Note's addressing fields. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NoteRecipient { + Followers(Iri), + Actor(Iri), +} + +/// Runtime-provided facts for constructing one local Note and Create activity. +/// +/// IDs and timestamps are inputs so core does not depend on clocks, randomness, +/// or an operating-system-specific identifier source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateNoteInput { + pub note_id: Iri, + pub create_id: Iri, + pub to: References, + pub cc: References, + pub content: String, + pub media_type: Option, + pub published: Option, + pub url: Option, +} + +/// The transient result of constructing one local Note. +/// +/// Core retains neither value. A runtime persists `note`; `activity` remains +/// available for subsequent delivery orchestration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateNoteOutcome { + pub note: Note, + pub activity: Create, + pub recipients: Vec, +} + +#[must_use] +pub fn create_note(local_actor: &Actor, input: CreateNoteInput) -> CreateNoteOutcome { + let actor = Reference::id(local_actor.id.clone()); + let mut note = Note::new(input.note_id); + note.attributed_to = Some(actor.clone()); + note.to = input.to; + note.cc = input.cc; + note.content = Some(input.content); + note.media_type = input.media_type; + note.published = input.published; + note.url = input.url; + + let mut activity = Create::new(input.create_id, actor, Reference::object(note.clone())); + activity.to = note.to.clone(); + activity.cc = note.cc.clone(); + + let recipients = note_recipients(local_actor, ¬e); + + CreateNoteOutcome { + note, + activity, + recipients, + } +} + +#[must_use] +pub fn is_public_note(note: &Note) -> bool { + note.to + .iter() + .chain(note.cc.iter()) + .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) +} + +fn note_recipients(local_actor: &Actor, note: &Note) -> Vec { + let mut recipients = Vec::new(); + + for address in note.to.iter().chain(note.cc.iter()) { + let recipient = if address.as_str() == PUBLIC_COLLECTION || address == &local_actor.id { + continue; + } else if local_actor.followers.as_ref() == Some(address) { + NoteRecipient::Followers(local_actor.id.clone()) + } else { + NoteRecipient::Actor(address.clone()) + }; + + if !recipients.contains(&recipient) { + recipients.push(recipient); + } + } + + recipients +} diff --git a/crates/feder-core/src/storage.rs b/crates/feder-core/src/storage.rs new file mode 100644 index 0000000..0f2e25d --- /dev/null +++ b/crates/feder-core/src/storage.rs @@ -0,0 +1,68 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::vec::Vec; + +use feder_vocab::{Actor, Iri, Note}; + +use crate::follow::PendingFollow; +#[cfg(feature = "http-signatures")] +use crate::key::ActorKeyPair; + +pub trait Storage { + type Error; +} + +pub trait ServerStorage: Storage { + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; + + #[cfg(feature = "http-signatures")] + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; + + fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error>; + + fn list_followers(&self, following: &Iri) -> Result, Self::Error>; + + fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error>; + + fn load_pending_follow( + &self, + follow_activity: &Iri, + ) -> Result, Self::Error>; + + /// Confirm `expected` only if that exact relationship is still pending. + fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result; +} + +pub trait NoteStore: Storage { + fn store_note(&self, note: &Note) -> Result<(), Self::Error>; + + fn load_note(&self, note_id: &Iri) -> Result, Self::Error>; +} + +/// The stored addressing facts needed to deliver an activity to a follower. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FollowerDeliveryTarget { + pub actor_id: Iri, + pub inbox: Iri, + pub shared_inbox: Option, +} + +pub trait FollowerDeliveryStore: ServerStorage { + fn list_follower_delivery_targets( + &self, + local_actor: &Iri, + ) -> Result, Self::Error>; +} diff --git a/crates/feder-core/src/undo.rs b/crates/feder-core/src/undo.rs new file mode 100644 index 0000000..07d017b --- /dev/null +++ b/crates/feder-core/src/undo.rs @@ -0,0 +1,79 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::fmt; + +use feder_vocab::{Actor, Iri, Reference, Undo}; + +/// The transient result of undoing one valid Follow activity. +/// +/// Core does not retain this value or remove anything from storage. A runtime +/// passes `follower` and `following` to its follower-removal capability. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UndoFollowOutcome { + pub follower: Iri, + pub following: Iri, +} + +pub fn receive_undo_follow( + local_actor: &Actor, + remote_actor: &Actor, + undo: Undo, +) -> Result { + if reference_id(&undo.actor) != &remote_actor.id { + return Err(UndoFollowError::WrongActor); + } + + let Reference::Object(follow) = undo.object else { + return Err(UndoFollowError::LinkedFollow); + }; + if reference_id(&follow.actor) != &remote_actor.id { + return Err(UndoFollowError::WrongActor); + } + if reference_id(&follow.object) != &local_actor.id { + return Err(UndoFollowError::WrongObject); + } + + Ok(UndoFollowOutcome { + follower: remote_actor.id.clone(), + following: local_actor.id.clone(), + }) +} + +fn reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(actor) => &actor.id, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UndoFollowError { + LinkedFollow, + WrongActor, + WrongObject, +} + +impl fmt::Display for UndoFollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LinkedFollow => formatter.write_str("Undo does not embed its Follow activity"), + Self::WrongActor => formatter.write_str("Undo actor does not own the embedded Follow"), + Self::WrongObject => formatter.write_str("undone Follow does not target local actor"), + } + } +} + +impl core::error::Error for UndoFollowError {} diff --git a/crates/feder-core/tests/common/mod.rs b/crates/feder-core/tests/common/mod.rs new file mode 100644 index 0000000..5a9593b --- /dev/null +++ b/crates/feder-core/tests/common/mod.rs @@ -0,0 +1,13 @@ +use feder_vocab::{Actor, Iri}; + +pub fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +pub fn actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} diff --git a/crates/feder-core/tests/fixtures/rsa-other-public-key.pem b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem new file mode 100644 index 0000000..86fa3a6 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo9l6PexyP7BtQOAvgpFV +Sb+iFq5jPL3VoFdr1jevGhBHslqP881CUQHOsSzjJFDkHFTHhN30uuucceAajt9N +IpPAoS9Ft6ouYDheg/MZEPNvbnUuen7IAx1hOSAGLzdXKHIFbQp9eBEcc8pKXuR5 +mhowALUjioqA7Ax3N6iw7uI7ANzrMX+VSmCmuHgUcXbJUy/4SqCmI1M3xyKvxEFh ++KPQCMYRpIh02j6tFU5k7P5aynDitPwqXJr1w+NVcLl1qh4y4tGQ6GpscS5KGEqd +sojcVZuweV0hku4fT5onA2WAd4CWZVUVM3gBau1OzTgyCajjbgWURQjH9DrAHSrO +FQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-private-key.pem b/crates/feder-core/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-public-key.pem b/crates/feder-core/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-core/tests/follow.rs b/crates/feder-core/tests/follow.rs new file mode 100644 index 0000000..cb73453 --- /dev/null +++ b/crates/feder-core/tests/follow.rs @@ -0,0 +1,142 @@ +mod common; + +use feder_core::follow::{ + AcceptFollowError, FollowError, PendingFollow, create_follow, receive_accept_follow, + receive_follow, +}; +use feder_vocab::{Accept, Follow, Reference}; + +use common::{actor, iri}; + +#[test] +fn creates_outbound_follow_and_pending_relationship() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow_id = iri("https://local.example/activities/follow/1"); + + let outcome = create_follow(&local, &remote, follow_id.clone()); + + assert_eq!(outcome.relationship.local_actor, local.id); + assert_eq!(outcome.relationship.remote_actor, remote); + assert_eq!(outcome.relationship.follow_activity, follow_id); + assert_eq!(outcome.activity.id, outcome.relationship.follow_activity); + assert_eq!(outcome.activity.actor, Reference::id(local.id)); + assert_eq!( + outcome.activity.object, + Reference::id(outcome.relationship.remote_actor.id) + ); +} + +#[test] +fn receives_follow_as_transient_storage_and_delivery_outcome() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + ); + + let outcome = receive_follow( + &local, + &remote, + follow.clone(), + iri("https://local.example/activities/accept/1"), + ) + .expect("valid Follow"); + + assert_eq!(outcome.follower, remote); + assert_eq!(outcome.following, local.id); + assert_eq!(outcome.recipient_inbox, outcome.follower.inbox); + assert_eq!(outcome.accept.actor, Reference::id(outcome.following)); + let Reference::Object(accepted_follow) = outcome.accept.object else { + panic!("Accept must embed the verified Follow"); + }; + assert_eq!(accepted_follow.id, follow.id); + assert_eq!(accepted_follow.object, follow.object); + assert_eq!( + accepted_follow.actor, + Reference::object(outcome.follower.clone()) + ); +} + +#[test] +fn rejects_follow_with_wrong_actor_or_object() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let accept_id = iri("https://local.example/activities/accept/1"); + let wrong_actor = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(other.id), + Reference::id(local.id.clone()), + ); + let wrong_object = Follow::new( + iri("https://remote.example/activities/follow/2"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/users/mallory")), + ); + + assert_eq!( + receive_follow(&local, &remote, wrong_actor, accept_id.clone()), + Err(FollowError::WrongActor) + ); + assert_eq!( + receive_follow(&local, &remote, wrong_object, accept_id), + Err(FollowError::WrongObject) + ); +} + +#[test] +fn confirms_accept_for_the_exact_pending_follow() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let pending = PendingFollow { + local_actor: local.id.clone(), + remote_actor: remote.clone(), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + let follow = Follow::new( + pending.follow_activity.clone(), + Reference::id(local.id.clone()), + Reference::id(remote.id.clone()), + ); + let accept = Accept::new( + iri("https://remote.example/activities/accept/1"), + Reference::id(remote.id.clone()), + Reference::object(follow), + ); + + receive_accept_follow(&local, &remote, &pending, accept).expect("valid Accept"); +} + +#[test] +fn rejects_accept_that_does_not_match_pending_relationship() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let pending = PendingFollow { + local_actor: local.id.clone(), + remote_actor: remote.clone(), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + let wrong_actor = Accept::new( + iri("https://remote.example/activities/accept/1"), + Reference::id(other.id), + Reference::id(pending.follow_activity.clone()), + ); + let wrong_follow = Accept::new( + iri("https://remote.example/activities/accept/2"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/activities/follow/other")), + ); + + assert_eq!( + receive_accept_follow(&local, &remote, &pending, wrong_actor), + Err(AcceptFollowError::WrongActor) + ); + assert_eq!( + receive_accept_follow(&local, &remote, &pending, wrong_follow), + Err(AcceptFollowError::WrongFollow) + ); +} diff --git a/crates/feder-core/tests/key.rs b/crates/feder-core/tests/key.rs new file mode 100644 index 0000000..e0d94df --- /dev/null +++ b/crates/feder-core/tests/key.rs @@ -0,0 +1,72 @@ +use feder_core::key::{ + ActorKeyPair, KeyError, create_sha256_digest_header, sign_draft_cavage, verify_draft_cavage, +}; + +const PRIVATE_KEY_PEM: &str = include_str!("fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-public-key.pem"); +const OTHER_PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-other-public-key.pem"); + +fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair") +} + +#[test] +fn rejects_mismatched_persisted_keys() { + let result = ActorKeyPair::from_pem( + PRIVATE_KEY_PEM.to_string(), + OTHER_PUBLIC_KEY_PEM.to_string(), + ); + + assert!(matches!(result, Err(KeyError::MismatchedKeyPair))); +} + +#[test] +fn redacts_private_key_from_debug_output() { + let pair = actor_key_pair(); + let debug = format!("{pair:?}"); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(pair.private_key_pem())); +} + +#[test] +fn creates_known_sha256_digest() { + assert_eq!( + create_sha256_digest_header(b"Hello, world!"), + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" + ); +} + +#[test] +fn signs_and_verifies_draft_cavage_request() { + let pair = actor_key_pair(); + let headers = [ + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/inbox", &headers) + .expect("sign request"); + let signature = signature_header + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + + verify_draft_cavage(pair.public_key_pem(), "POST", "/inbox", &headers, signature) + .expect("verify request"); + assert!( + verify_draft_cavage( + pair.public_key_pem(), + "POST", + "/other-inbox", + &headers, + signature, + ) + .is_err() + ); +} diff --git a/crates/feder-core/tests/note.rs b/crates/feder-core/tests/note.rs new file mode 100644 index 0000000..ed891c6 --- /dev/null +++ b/crates/feder-core/tests/note.rs @@ -0,0 +1,92 @@ +mod common; + +use feder_core::note::{ + CreateNoteInput, NoteRecipient, PUBLIC_COLLECTION, create_note, is_public_note, +}; +use feder_vocab::{Note, Reference, References}; + +use common::{actor, iri}; + +#[test] +fn creates_note_and_create_activity_from_runtime_facts() { + let mut local = actor("https://local.example/users/alice"); + local.followers = Some(iri("https://local.example/users/alice/followers")); + let remote = iri("https://remote.example/users/bob"); + let input = CreateNoteInput { + note_id: iri("https://local.example/posts/1"), + create_id: iri("https://local.example/activities/create/1"), + to: References::one(iri(PUBLIC_COLLECTION)), + cc: References::many([local.followers.clone().expect("followers"), remote.clone()]), + content: "hello".to_string(), + media_type: Some("text/html".to_string()), + published: Some("2026-08-02T00:00:00Z".to_string()), + url: Some(iri("https://local.example/@alice/1")), + }; + + let outcome = create_note(&local, input); + + assert_eq!( + outcome.note.attributed_to, + Some(Reference::id(local.id.clone())) + ); + assert_eq!(outcome.note.content.as_deref(), Some("hello")); + assert_eq!( + outcome.activity.object, + Reference::object(outcome.note.clone()) + ); + assert_eq!(outcome.activity.actor, Reference::id(local.id.clone())); + assert_eq!(outcome.activity.to, outcome.note.to); + assert_eq!(outcome.activity.cc, outcome.note.cc); + assert_eq!( + outcome.recipients, + vec![ + NoteRecipient::Followers(local.id), + NoteRecipient::Actor(remote), + ] + ); +} + +#[test] +fn deduplicates_note_recipients_and_skips_public_and_local_addresses() { + let mut local = actor("https://local.example/users/alice"); + let followers = iri("https://local.example/users/alice/followers"); + local.followers = Some(followers.clone()); + let remote = iri("https://remote.example/users/bob"); + let outcome = create_note( + &local, + CreateNoteInput { + note_id: iri("https://local.example/posts/1"), + create_id: iri("https://local.example/activities/create/1"), + to: References::many([ + iri(PUBLIC_COLLECTION), + local.id.clone(), + followers.clone(), + remote.clone(), + ]), + cc: References::many([followers, remote.clone()]), + content: "hello".to_string(), + media_type: None, + published: None, + url: None, + }, + ); + + assert_eq!( + outcome.recipients, + vec![ + NoteRecipient::Followers(local.id), + NoteRecipient::Actor(remote), + ] + ); +} + +#[test] +fn recognizes_public_note_addressing() { + let mut public = Note::new(iri("https://local.example/posts/1")); + public.cc = References::one(iri(PUBLIC_COLLECTION)); + let mut private = Note::new(iri("https://local.example/posts/2")); + private.to = References::one(iri("https://remote.example/users/bob")); + + assert!(is_public_note(&public)); + assert!(!is_public_note(&private)); +} diff --git a/crates/feder-core/tests/received_follow_decider.rs b/crates/feder-core/tests/received_follow_decider.rs deleted file mode 100644 index 18ada96..0000000 --- a/crates/feder-core/tests/received_follow_decider.rs +++ /dev/null @@ -1,242 +0,0 @@ -use feder_core::vocab; -use feder_core::{ - Activity, CoreError, DecisionContext, Effect, FederCore, FollowPolicyDecision, - FollowRelationship, PlannedDelivery, ReceivedFollowState, RemoteActorState, StateChange, -}; - -fn iri(value: &str) -> vocab::Iri { - value.parse().expect("valid test IRI") -} - -fn core() -> FederCore { - FederCore::new() -} - -fn follow() -> vocab::Follow { - vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::id(iri("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ) -} - -fn received_follow_state( - relationship: FollowRelationship, - inbox: Option<&str>, - shared_inbox: Option<&str>, -) -> ReceivedFollowState { - ReceivedFollowState { - already_processed: false, - relationship, - remote_actor: Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: inbox.map(iri), - shared_inbox: shared_inbox.map(iri), - }), - } -} - -fn decision_context() -> DecisionContext { - DecisionContext { - local_actor: iri("https://example.com/users/alice"), - accept_id: iri("https://example.com/activities/accept/1"), - } -} - -#[test] -fn accepts_new_follower() { - let follow = follow(); - let decision = core() - .decide_received_follow( - follow.clone(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[0], - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow/1") - } - ); - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - } - ); - - let accept = match &decision.state_changes[2] { - StateChange::StoreActivity { - activity: Activity::Accept(accept), - } => accept, - _ => panic!("expected stored Accept activity"), - }; - assert_eq!(accept.id, iri("https://example.com/activities/accept/1")); - assert_eq!( - accept.actor, - vocab::Reference::id(iri("https://example.com/users/alice")) - ); - assert_eq!(accept.object, vocab::Reference::object(follow)); - - assert_eq!( - decision.effects, - [Effect::PlanDelivery(PlannedDelivery { - activity: Activity::Accept(accept.clone()), - inbox: iri("https://remote.example/users/bob/inbox"), - })] - ); -} - -#[test] -fn uses_shared_inbox_for_delivery() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - match &decision.effects[0] { - Effect::PlanDelivery(delivery) => { - assert_eq!(delivery.inbox, iri("https://remote.example/inbox")); - } - _ => panic!("expected planned delivery"), - } -} - -#[test] -fn already_processed_activity_is_idempotent() { - let mut state = received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ); - state.already_processed = true; - - let decision = core() - .decide_received_follow( - follow(), - state, - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert!(decision.is_empty()); -} - -#[test] -fn existing_follower_endpoint_metadata_is_refreshed() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::Following, - Some("https://remote.example/users/bob/new-inbox"), - Some("https://remote.example/shared-inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/new-inbox")), - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - } - ); - assert_eq!(decision.effects.len(), 1); -} - -#[test] -fn existing_follower_can_refresh_to_shared_inbox_only() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::Following, - None, - Some("https://remote.example/shared-inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: None, - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - } - ); - assert_eq!( - decision.effects, - [Effect::PlanDelivery(PlannedDelivery { - activity: match &decision.state_changes[2] { - StateChange::StoreActivity { activity } => activity.clone(), - _ => panic!("expected stored Accept activity"), - }, - inbox: iri("https://remote.example/shared-inbox"), - })] - ); -} - -#[test] -fn missing_inbox_returns_error() { - let err = core() - .decide_received_follow( - follow(), - received_follow_state(FollowRelationship::NotFollowing, None, None), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect_err("missing inbox should fail"); - - assert_eq!(err, CoreError::MissingInbox); -} - -#[test] -fn non_accept_policy_has_no_protocol_side_effects() { - for policy in [ - FollowPolicyDecision::Reject, - FollowPolicyDecision::RequireManualApproval, - ] { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ), - policy, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert!(decision.is_empty()); - } -} diff --git a/crates/feder-core/tests/undo.rs b/crates/feder-core/tests/undo.rs new file mode 100644 index 0000000..82dca5c --- /dev/null +++ b/crates/feder-core/tests/undo.rs @@ -0,0 +1,70 @@ +mod common; + +use feder_core::undo::{UndoFollowError, receive_undo_follow}; +use feder_vocab::{Follow, Reference, Undo}; + +use common::{actor, iri}; + +#[test] +fn receives_undo_for_embedded_follow() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + ); + let undo = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(remote.id.clone()), + Reference::object(follow), + ); + + let outcome = receive_undo_follow(&local, &remote, undo).expect("valid Undo"); + + assert_eq!(outcome.follower, remote.id); + assert_eq!(outcome.following, local.id); +} + +#[test] +fn rejects_linked_follow_and_wrong_actor_or_object() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let linked = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://remote.example/activities/follow/1")), + ); + let wrong_actor = Undo::new( + iri("https://remote.example/activities/undo/2"), + Reference::id(other.id), + Reference::object(Follow::new( + iri("https://remote.example/activities/follow/2"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + )), + ); + let wrong_object = Undo::new( + iri("https://remote.example/activities/undo/3"), + Reference::id(remote.id.clone()), + Reference::object(Follow::new( + iri("https://remote.example/activities/follow/3"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/users/mallory")), + )), + ); + + assert_eq!( + receive_undo_follow(&local, &remote, linked), + Err(UndoFollowError::LinkedFollow) + ); + assert_eq!( + receive_undo_follow(&local, &remote, wrong_actor), + Err(UndoFollowError::WrongActor) + ); + assert_eq!( + receive_undo_follow(&local, &remote, wrong_object), + Err(UndoFollowError::WrongObject) + ); +} diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml deleted file mode 100644 index 7981fea..0000000 --- a/crates/feder-runtime-server/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "feder-runtime-server" -description = "Runnable server runtime for Feder on standard operating systems." -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -feder-core.workspace = true -feder-vocab.workspace = true -axum = "0.8" -serde.workspace = true -thiserror = "2" -serde_json.workspace = true -percent-encoding = "2.3.2" -rusqlite = { version = "0.40.1", features = ["bundled"] } - -[dev-dependencies] -serde_json.workspace = true -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -tower.workspace = true - -[lints] -workspace = true diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md deleted file mode 100644 index a43f32d..0000000 --- a/crates/feder-runtime-server/README.md +++ /dev/null @@ -1,52 +0,0 @@ -Feder Runtime Server -==================== - -Reusable Axum/Tokio server integration for Feder on standard operating -systems. - -This crate builds an Axum router from caller-provided runtime configuration. -It provides a health check endpoint, WebFinger discovery, and a local actor -route. The caller chooses concrete bind addresses, actor IRIs, usernames, and -handle hosts. - -ActivityPub inbox handling for supported Follow activities is included. For a -received Follow, the runtime parses the request, loads stored Follow state, -asks `feder-core` for a decision, and applies the returned state changes and -queued delivery work in one SQLite transaction. - - -Example -------- - -~~~~ rust -use feder_runtime_server::{InboxAuthPolicy, RuntimeConfig, StorageConfig, build_router}; - -let config = RuntimeConfig { - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, -}; - -let app = build_router(config).expect("build router"); -~~~~ - - -Demo ----- - -A runnable single-user demo lives in `examples/single-user-server`: - -~~~~ sh -RUST_LOG=info cargo run -p single-user-server -~~~~ diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs deleted file mode 100644 index a57ea15..0000000 --- a/crates/feder-runtime-server/src/actor.rs +++ /dev/null @@ -1,102 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Path, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, -}; - -use crate::app::AppState; - -pub async fn actor( - State(app_state): State, - Path(username): Path, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - let local_actor = app_state.local_actor.clone(); - - Ok(( - [(header::CONTENT_TYPE, "application/activity+json")], - Json(local_actor), - ) - .into_response()) -} - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/alice") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "Person"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); - assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); - assert_eq!(json["preferredUsername"], "alice"); - assert_eq!(json["name"], "alice"); - } - - #[tokio::test] - async fn rejects_unknown_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/bob") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs deleted file mode 100644 index 53bbbdc..0000000 --- a/crates/feder-runtime-server/src/app.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::sync::{Arc, Mutex}; - -use crate::Error; -use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::storage::SqliteStore; -use crate::webfinger::webfinger; -use crate::{actor::actor, inbox::inbox}; -use axum::routing::post; -use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; -use feder_vocab::Actor; - -#[derive(Clone)] -pub struct AppState { - pub store: Arc>, - pub local_actor: Actor, - pub username: String, - pub handle_host: String, - pub inbox_auth_policy: InboxAuthPolicy, -} - -impl AppState { - pub fn from_config(config: RuntimeConfig) -> Result { - let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); - actor.preferred_username = Some(config.username.clone()); - actor.name = Some(config.username.clone()); - - let store = match &config.storage { - StorageConfig::InMemory => SqliteStore::open_in_memory()?, - StorageConfig::Sqlite { path } => SqliteStore::open(path)?, - }; - - Ok(Self { - store: Arc::new(Mutex::new(store)), - local_actor: actor, - username: config.username, - handle_host: config.handle_host, - inbox_auth_policy: config.inbox_auth_policy, - }) - } -} - -pub fn build_router(config: RuntimeConfig) -> Result { - let state = AppState::from_config(config)?; - - Ok(Router::new() - .route("/healthz", get(healthz)) - .route("/.well-known/webfinger", get(webfinger)) - .route("/users/{username}", get(actor)) - .route("/users/{username}/inbox", post(inbox)) - .layer(DefaultBodyLimit::max(1_048_576)) - .with_state(state)) -} - -async fn healthz() -> StatusCode { - StatusCode::NO_CONTENT -} - -#[cfg(test)] -mod tests { - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_health_check() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - } -} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs deleted file mode 100644 index fb1d7b5..0000000 --- a/crates/feder-runtime-server/src/config.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{net::SocketAddr, path::PathBuf}; - -use feder_vocab::Iri; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InboxAuthPolicy { - RequireSigned, - AllowUnsignedInsecureDev, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum StorageConfig { - InMemory, - Sqlite { path: PathBuf }, -} - -pub struct RuntimeConfig { - pub bind: SocketAddr, - pub actor_id: Iri, - pub inbox: Iri, - pub outbox: Iri, - pub username: String, - pub handle_host: String, - pub inbox_auth_policy: InboxAuthPolicy, - pub storage: StorageConfig, -} - -#[cfg(test)] -pub(crate) fn test_config() -> RuntimeConfig { - RuntimeConfig { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, - } -} diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs deleted file mode 100644 index 0e0a128..0000000 --- a/crates/feder-runtime-server/src/inbox.rs +++ /dev/null @@ -1,515 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - body::Bytes, - extract::{Path, State}, - http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, - response::{IntoResponse, Response}, -}; - -use feder_core::{CoreError, DecisionContext, FederCore, FollowPolicyDecision}; -use feder_vocab::Follow; -use serde_json::{Value, from_slice, from_value}; - -use crate::app::AppState; -use crate::config::InboxAuthPolicy; -use crate::storage::RuntimeStore; - -pub struct InboxRequest { - pub username: String, - pub headers: HeaderMap, - pub method: Method, - pub uri: Uri, - pub body: Bytes, -} - -fn accept_id_for_follow( - local_actor_id: &feder_vocab::Iri, - follow_id: &feder_vocab::Iri, -) -> Result { - let encoded_follow_id = percent_encoding::utf8_percent_encode( - follow_id.as_str(), - percent_encoding::NON_ALPHANUMERIC, - ); - - format!("{local_actor_id}#accepts/{encoded_follow_id}") - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} - -fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), StatusCode> { - match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), - InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), - } -} - -fn status_for_core_error(error: CoreError) -> StatusCode { - match error { - CoreError::MissingRemoteActor | CoreError::MissingInbox => StatusCode::BAD_REQUEST, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -pub async fn inbox( - State(app_state): State, - Path(username): Path, - headers: HeaderMap, - method: Method, - uri: Uri, - body: Bytes, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - let content_type = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - - if !content_type.starts_with("application/activity+json") - && !content_type.starts_with("application/ld+json") - { - return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); - } - - let req = InboxRequest { - username, - headers, - method, - uri, - body, - }; - - verify_inbox_request(&app_state, &req)?; - - let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; - - let activity_type = value.get("type").and_then(|value| value.as_str()); - - // Unsupported activity types will be ignored - if activity_type != Some("Follow") { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - let context = DecisionContext { - local_actor: app_state.local_actor.id.clone(), - accept_id, - }; - - let mut store = app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let state = store - .load_received_follow_state(&follow, &app_state.local_actor.id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let decision = FederCore::new() - .decide_received_follow(follow, state, FollowPolicyDecision::Accept, context) - .map_err(status_for_core_error)?; - - store - .apply_decision(&decision) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(StatusCode::ACCEPTED.into_response()) -} - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, Bytes}, - extract::Path, - http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, - response::Response, - }; - use feder_vocab::{Follow, Iri}; - use serde_json::json; - use tower::ServiceExt; - - use crate::{ - app::AppState, - build_router, - config::{InboxAuthPolicy, StorageConfig, test_config}, - storage::RuntimeStore, - }; - - use super::inbox; - - fn activity_json_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); - headers - } - - fn follow_body() -> Bytes { - Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": "https://remote.example/activities/follow-1", - "actor": { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Person", - "id": "https://remote.example/users/bob", - "inbox": "https://remote.example/users/bob/inbox", - "outbox": "https://remote.example/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow"), - ) - } - - fn follow_body_with_actor_id_only() -> Bytes { - Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": "https://remote.example/activities/follow-1", - "actor": "https://remote.example/users/bob", - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow"), - ) - } - - fn local_actor_id() -> Iri { - "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI") - } - - fn follow_from_body(body: &Bytes) -> Follow { - serde_json::from_slice(body).expect("deserialize follow body") - } - - async fn post_inbox( - app_state: AppState, - username: &str, - headers: HeaderMap, - body: Bytes, - ) -> Result { - inbox( - axum::extract::State(app_state), - Path(username.to_string()), - headers, - Method::POST, - Uri::from_static("/users/alice/inbox"), - body, - ) - .await - } - - fn assert_no_stored_followers(app_state: &AppState) { - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert!(followers.is_empty()); - } - - #[tokio::test] - async fn valid_follow_is_applied_through_storage_decision() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - body.clone(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - let store = app_state.store.lock().expect("store lock"); - let followers = store - .list_followers(&local_actor_id()) - .expect("list followers"); - assert_eq!(followers.len(), 1); - assert_eq!( - followers[0].follower.as_str(), - "https://remote.example/users/bob" - ); - assert_eq!( - followers[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" - ); - assert_eq!( - followers[0].inbox.as_ref().map(|iri| iri.as_str()), - Some("https://remote.example/users/bob/inbox") - ); - - let follow = follow_from_body(&body); - let state = store - .load_received_follow_state(&follow, &local_actor_id()) - .expect("load received follow state"); - - assert!(state.already_processed); - } - - #[tokio::test] - async fn duplicate_follow_is_idempotent() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); - - let first_response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - body.clone(), - ) - .await - .expect("accepted first follow"); - let second_response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) - .await - .expect("accepted duplicate follow"); - - assert_eq!(first_response.status(), StatusCode::ACCEPTED); - assert_eq!(second_response.status(), StatusCode::ACCEPTED); - - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - } - - #[tokio::test] - async fn concurrent_duplicate_follow_is_idempotent() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); - - let (first_response, second_response) = tokio::join!( - post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - body.clone(), - ), - post_inbox(app_state.clone(), "alice", activity_json_headers(), body), - ); - - assert_eq!( - first_response.expect("accepted first follow").status(), - StatusCode::ACCEPTED - ); - assert_eq!( - second_response.expect("accepted duplicate follow").status(), - StatusCode::ACCEPTED - ); - - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - } - - #[tokio::test] - async fn rejects_follow_without_known_inbox() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body_with_actor_id_only(), - ) - .await - .expect_err("follow without known inbox should be rejected"); - - assert_eq!(error, StatusCode::BAD_REQUEST); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn require_signed_rejects_unsigned_follow_before_core() { - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let app_state = AppState::from_config(config).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unsigned follow should be rejected"); - - assert_eq!(error, StatusCode::UNAUTHORIZED); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn rejects_unknown_inbox_actor() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "bob", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unknown inbox actor should be rejected"); - - assert_eq!(error, StatusCode::NOT_FOUND); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn rejects_unsupported_content_type() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); - - let error = post_inbox(app_state.clone(), "alice", headers, follow_body()) - .await - .expect_err("unsupported content type should be rejected"); - - assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn rejects_malformed_json() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - Bytes::from_static(b"{not json"), - ) - .await - .expect_err("malformed json should be rejected"); - - assert_eq!(error, StatusCode::BAD_REQUEST); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn ignores_unsupported_activity_without_applying_decision() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Create", - "id": "https://remote.example/activities/create-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Note", - "id": "https://remote.example/notes/1" - } - })) - .expect("serialize create"), - ); - - let response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) - .await - .expect("unsupported activity is accepted but ignored"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_no_stored_followers(&app_state); - } - - #[tokio::test] - async fn rejects_oversized_inbox_body() { - let app = build_router(test_config()).expect("build router"); - let oversized_body = vec![b' '; 1_048_577]; - - let response = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/users/alice/inbox") - .header(CONTENT_TYPE, "application/activity+json") - .body(Body::from(oversized_body)) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn sqlite_storage_persists_followers_across_app_state_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-runtime-server-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("build app state"); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - drop(app_state); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("reopen app state"); - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers( - &"http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI"), - ) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - assert_eq!( - followers[0].follower.as_str(), - "https://remote.example/users/bob" - ); - - let _ = std::fs::remove_file(path); - } -} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs deleted file mode 100644 index 598520f..0000000 --- a/crates/feder-runtime-server/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -pub mod actor; -pub mod app; -pub mod config; -pub mod error; -pub mod inbox; -pub mod storage; -pub mod webfinger; - -pub use app::{AppState, build_router}; -pub use config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -pub use error::Error; diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs deleted file mode 100644 index 6035fac..0000000 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -pub mod sqlite; - -use feder_core::{Decision, ReceivedFollowState}; -use feder_vocab::Follow; -use feder_vocab::Iri; -pub use sqlite::SqliteStore; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoredFollower { - pub follower: Iri, - pub following: Iri, - pub inbox: Option, - pub shared_inbox: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoredRecipient { - pub actor_id: Iri, - pub inbox: Iri, - pub shared_inbox: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum StoreError { - #[error("sqlite error")] - Sqlite(#[from] rusqlite::Error), - - #[error("json error")] - Json(#[from] serde_json::Error), - - #[error("invalid IRI: {0}")] - InvalidIri(String), - - #[error("unsupported core decision value: {0}")] - UnsupportedDecisionValue(&'static str), -} - -pub trait RuntimeStore { - fn apply_decision(&mut self, decision: &Decision) -> Result<(), StoreError>; - - fn load_received_follow_state( - &self, - follow: &Follow, - local_actor_id: &Iri, - ) -> Result; - - fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; - - fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; -} diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs deleted file mode 100644 index 900531f..0000000 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ /dev/null @@ -1,1013 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::path::Path; - -use feder_core::{ - Activity, Decision, Effect, FollowRelationship, Object, ReceivedFollowState, RemoteActorState, - StateChange, -}; -use feder_vocab::{Actor, Iri, Reference}; -use rusqlite::{Connection, Transaction, params}; - -use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; - -pub struct SqliteStore { - conn: Connection, -} - -impl SqliteStore { - pub fn open(path: &Path) -> Result { - let store = Self { - conn: Connection::open(path)?, - }; - - store.init()?; - - Ok(store) - } - - pub fn open_in_memory() -> Result { - let store = Self { - conn: Connection::open_in_memory()?, - }; - - store.init()?; - - Ok(store) - } - - pub fn init(&self) -> Result<(), StoreError> { - self.conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS followers ( - follower_actor_id TEXT NOT NULL, - following_actor_id TEXT NOT NULL, - inbox_url TEXT, - shared_inbox_url TEXT, - PRIMARY KEY (follower_actor_id, following_actor_id) - ); - CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id - ON followers (following_actor_id); - - CREATE TABLE IF NOT EXISTS processed_inbox_activities ( - activity_id TEXT PRIMARY KEY - ); - - CREATE TABLE IF NOT EXISTS activities ( - activity_id TEXT PRIMARY KEY, - activity_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS objects ( - object_id TEXT PRIMARY KEY, - object_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS outgoing_deliveries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id TEXT NOT NULL, - activity_json TEXT NOT NULL, - inbox_url TEXT NOT NULL - ); - "#, - )?; - - Ok(()) - } -} - -impl RuntimeStore for SqliteStore { - fn apply_decision(&mut self, decision: &Decision) -> Result<(), StoreError> { - let tx = self.conn.transaction()?; - - for change in &decision.state_changes { - if let StateChange::RecordProcessedActivity { activity_id } = change - && !record_processed_activity(&tx, activity_id)? - { - tx.rollback()?; - return Ok(()); - } - } - - for change in &decision.state_changes { - if matches!(change, StateChange::RecordProcessedActivity { .. }) { - continue; - } - - apply_state_change(&tx, change)?; - } - - for effect in &decision.effects { - apply_effect(&tx, effect)?; - } - - tx.commit()?; - - Ok(()) - } - - fn load_received_follow_state( - &self, - follow: &feder_vocab::Follow, - local_actor_id: &Iri, - ) -> Result { - let already_processed = self.conn.query_row( - r#" - SELECT EXISTS( - SELECT 1 - FROM processed_inbox_activities - WHERE activity_id = ?1 - ) - "#, - [follow.id.as_str()], - |row| row.get::<_, bool>(0), - )?; - - let follower_id = actor_reference_id(&follow.actor); - let stored_follower = self.load_stored_follower(follower_id, local_actor_id)?; - let relationship = if stored_follower.is_some() { - FollowRelationship::Following - } else { - FollowRelationship::NotFollowing - }; - - let stored_inbox = stored_follower - .as_ref() - .and_then(|follower| follower.inbox.clone()); - let stored_shared_inbox = stored_follower - .as_ref() - .and_then(|follower| follower.shared_inbox.clone()); - let remote_actor = match &follow.actor { - Reference::Object(actor) => RemoteActorState { - actor_id: actor.id.clone(), - inbox: Some(actor.inbox.clone()), - shared_inbox: actor - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.clone()), - }, - Reference::Id(actor_id) => RemoteActorState { - actor_id: actor_id.clone(), - inbox: stored_inbox, - shared_inbox: stored_shared_inbox, - }, - }; - - Ok(ReceivedFollowState { - already_processed, - relationship, - remote_actor: Some(remote_actor), - }) - } - - fn list_followers(&self, actor_id: &Iri) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - r#" - SELECT follower_actor_id, following_actor_id, inbox_url, shared_inbox_url - FROM followers - WHERE following_actor_id = ?1 - ORDER BY follower_actor_id, following_actor_id - "#, - )?; - let rows = stmt.query_map([actor_id.as_str()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - })?; - - rows.map(|row| { - let (follower, following, inbox, shared_inbox) = row?; - Ok(StoredFollower { - follower: parse_iri(follower)?, - following: parse_iri(following)?, - inbox: parse_optional_iri(inbox)?, - shared_inbox: parse_optional_iri(shared_inbox)?, - }) - }) - .collect() - } - - fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - r#" - SELECT follower_actor_id, inbox_url, shared_inbox_url - FROM followers - WHERE following_actor_id = ?1 - AND inbox_url IS NOT NULL - ORDER BY follower_actor_id - "#, - )?; - let rows = stmt.query_map([actor_id.as_str()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - })?; - - rows.map(|row| { - let (actor_id, inbox, shared_inbox) = row?; - Ok(StoredRecipient { - actor_id: parse_iri(actor_id)?, - inbox: parse_iri(inbox)?, - shared_inbox: parse_optional_iri(shared_inbox)?, - }) - }) - .collect() - } -} - -impl SqliteStore { - fn load_stored_follower( - &self, - follower: &Iri, - following: &Iri, - ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - r#" - SELECT follower_actor_id, following_actor_id, inbox_url, shared_inbox_url - FROM followers - WHERE follower_actor_id = ?1 - AND following_actor_id = ?2 - "#, - )?; - let mut rows = stmt.query(params![follower.as_str(), following.as_str()])?; - - let Some(row) = rows.next()? else { - return Ok(None); - }; - - Ok(Some(StoredFollower { - follower: parse_iri(row.get::<_, String>(0)?)?, - following: parse_iri(row.get::<_, String>(1)?)?, - inbox: parse_optional_iri(row.get::<_, Option>(2)?)?, - shared_inbox: parse_optional_iri(row.get::<_, Option>(3)?)?, - })) - } -} - -fn record_processed_activity(tx: &Transaction<'_>, activity_id: &Iri) -> Result { - let inserted = tx.execute( - "INSERT OR IGNORE INTO processed_inbox_activities (activity_id) VALUES (?1)", - [activity_id.as_str()], - )?; - - Ok(inserted > 0) -} - -fn apply_state_change(tx: &Transaction<'_>, change: &StateChange) -> Result<(), StoreError> { - match change { - StateChange::RecordProcessedActivity { .. } => {} - StateChange::AddFollower { - local_actor, - remote_actor, - inbox, - shared_inbox, - } => { - tx.execute( - r#" - INSERT INTO followers ( - follower_actor_id, - following_actor_id, - inbox_url, - shared_inbox_url - ) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(follower_actor_id, following_actor_id) DO UPDATE SET - inbox_url = excluded.inbox_url, - shared_inbox_url = excluded.shared_inbox_url - "#, - params![ - remote_actor.as_str(), - local_actor.as_str(), - inbox.as_ref().map(|inbox| inbox.as_str()), - shared_inbox - .as_ref() - .map(|shared_inbox| shared_inbox.as_str()), - ], - )?; - } - StateChange::StoreActivity { activity } => { - let activity_id = activity_id(activity)?; - tx.execute( - r#" - INSERT INTO activities (activity_id, activity_json) - VALUES (?1, ?2) - ON CONFLICT(activity_id) DO UPDATE SET - activity_json = excluded.activity_json - "#, - params![activity_id.as_str(), serialize_activity(activity)?], - )?; - } - StateChange::StoreObject { object } => { - let object_id = object_id(object)?; - tx.execute( - r#" - INSERT INTO objects (object_id, object_json) - VALUES (?1, ?2) - ON CONFLICT(object_id) DO UPDATE SET - object_json = excluded.object_json - "#, - params![object_id.as_str(), serialize_object(object)?], - )?; - } - _ => return Err(StoreError::UnsupportedDecisionValue("state change")), - } - - Ok(()) -} - -fn apply_effect(tx: &Transaction<'_>, effect: &Effect) -> Result<(), StoreError> { - match effect { - Effect::PlanDelivery(delivery) => { - let activity_id = activity_id(&delivery.activity)?; - tx.execute( - r#" - INSERT INTO outgoing_deliveries ( - activity_id, - activity_json, - inbox_url - ) - VALUES (?1, ?2, ?3) - "#, - params![ - activity_id.as_str(), - serialize_activity(&delivery.activity)?, - delivery.inbox.as_str(), - ], - )?; - } - _ => return Err(StoreError::UnsupportedDecisionValue("effect")), - } - - Ok(()) -} - -fn activity_id(activity: &Activity) -> Result<&Iri, StoreError> { - match activity { - Activity::Accept(activity) => Ok(&activity.id), - Activity::CreateNote(activity) => Ok(&activity.id), - _ => Err(StoreError::UnsupportedDecisionValue("activity")), - } -} - -fn object_id(object: &Object) -> Result<&Iri, StoreError> { - match object { - Object::Note(object) => Ok(&object.id), - _ => Err(StoreError::UnsupportedDecisionValue("object")), - } -} - -fn serialize_activity(activity: &Activity) -> Result { - match activity { - Activity::Accept(activity) => Ok(serde_json::to_string(activity)?), - Activity::CreateNote(activity) => Ok(serde_json::to_string(activity)?), - _ => Err(StoreError::UnsupportedDecisionValue("activity")), - } -} - -fn serialize_object(object: &Object) -> Result { - match object { - Object::Note(object) => Ok(serde_json::to_string(object)?), - _ => Err(StoreError::UnsupportedDecisionValue("object")), - } -} - -fn actor_reference_id(reference: &Reference) -> &Iri { - match reference { - Reference::Id(id) => id, - Reference::Object(actor) => &actor.id, - } -} - -fn parse_iri(value: String) -> Result { - value - .parse() - .map_err(|_| StoreError::InvalidIri(value.to_owned())) -} - -fn parse_optional_iri(value: Option) -> Result, StoreError> { - value.map(parse_iri).transpose() -} - -#[cfg(test)] -mod tests { - use feder_core::{ - Activity, Decision, Effect, FollowRelationship, PlannedDelivery, StateChange, - }; - - use super::*; - - fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") - } - - fn add_follower_decision( - remote_actor: &str, - local_actor: &str, - inbox: Option<&str>, - shared_inbox: Option<&str>, - ) -> Decision { - Decision { - state_changes: vec![StateChange::AddFollower { - local_actor: iri(local_actor), - remote_actor: iri(remote_actor), - inbox: inbox.map(iri), - shared_inbox: shared_inbox.map(iri), - }], - effects: Vec::new(), - } - } - - fn bob_follows_alice_decision() -> Decision { - add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - None, - None, - ) - } - - fn actor(id: &str) -> Actor { - Actor::person( - iri(id), - iri(&format!("{id}/inbox")), - iri(&format!("{id}/outbox")), - ) - } - - fn follow(actor: Reference) -> feder_vocab::Follow { - feder_vocab::Follow::new( - iri("https://remote.example/activities/follow-1"), - actor, - Reference::id(iri("https://example.com/users/alice")), - ) - } - - fn accept_activity() -> Activity { - Activity::Accept(feder_vocab::Accept::new( - iri("https://example.com/activities/accept-1"), - Reference::id(iri("https://example.com/users/alice")), - Reference::object(follow(Reference::id(iri( - "https://remote.example/users/bob", - )))), - )) - } - - #[test] - fn apply_decision_stores_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply follower decision"); - - let (follower, following): (String, String) = store - .conn - .query_row( - "SELECT follower_actor_id, following_actor_id FROM followers", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .expect("query stored follower"); - - assert_eq!(follower, "https://remote.example/users/bob"); - assert_eq!(following, "https://example.com/users/alice"); - } - - #[test] - fn apply_decision_ignores_duplicate_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let decision = bob_follows_alice_decision(); - - store - .apply_decision(&decision) - .expect("apply follower decision first time"); - store - .apply_decision(&decision) - .expect("apply follower decision second time"); - - let follower_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) - .expect("query follower count"); - - assert_eq!(follower_count, 1); - } - - #[test] - fn apply_decision_updates_follower_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply ID-only follower decision"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/updated-inbox"), - None, - )) - .expect("apply updated follower decision"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/updated-inbox"), - shared_inbox: None, - }] - ); - } - - #[test] - fn apply_decision_clears_stale_shared_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/old-inbox"), - Some("https://remote.example/old-shared-inbox"), - )) - .expect("apply original follower decision"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/current-inbox"), - None, - )) - .expect("apply refreshed follower decision"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/current-inbox"), - shared_inbox: None, - }] - ); - } - - #[test] - fn apply_decision_persists_state_changes_and_queues_delivery() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let activity = accept_activity(); - let decision = Decision { - state_changes: vec![ - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow-1"), - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }, - StateChange::StoreActivity { - activity: activity.clone(), - }, - ], - effects: vec![Effect::PlanDelivery(PlannedDelivery { - activity, - inbox: iri("https://remote.example/inbox"), - })], - }; - - store - .apply_decision(&decision) - .expect("apply core decision"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - assert!(state.already_processed); - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor.expect("remote actor state").shared_inbox, - Some(iri("https://remote.example/inbox")) - ); - - let stored_activity_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM activities", [], |row| row.get(0)) - .expect("count stored activities"); - let delivery_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM outgoing_deliveries", [], |row| { - row.get(0) - }) - .expect("count queued deliveries"); - let delivery_inbox: String = store - .conn - .query_row("SELECT inbox_url FROM outgoing_deliveries", [], |row| { - row.get(0) - }) - .expect("query queued delivery inbox"); - - assert_eq!(stored_activity_count, 1); - assert_eq!(delivery_count, 1); - assert_eq!(delivery_inbox, "https://remote.example/inbox"); - } - - #[test] - fn apply_decision_rolls_back_changes_before_duplicate_processed_activity() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - "INSERT INTO processed_inbox_activities (activity_id) VALUES (?1)", - ["https://remote.example/activities/follow-1"], - ) - .expect("insert processed inbox activity"); - - let decision = Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }, - StateChange::StoreActivity { - activity: accept_activity(), - }, - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow-1"), - }, - ], - effects: vec![Effect::PlanDelivery(PlannedDelivery { - activity: accept_activity(), - inbox: iri("https://remote.example/users/bob/inbox"), - })], - }; - - store - .apply_decision(&decision) - .expect("duplicate processed activity should be ignored"); - - assert!( - store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list followers") - .is_empty() - ); - assert_eq!( - store - .conn - .query_row("SELECT COUNT(*) FROM activities", [], |row| { - row.get::<_, i64>(0) - }) - .expect("count stored activities"), - 0 - ); - assert_eq!( - store - .conn - .query_row("SELECT COUNT(*) FROM outgoing_deliveries", [], |row| { - row.get::<_, i64>(0) - }) - .expect("count queued deliveries"), - 0 - ); - } - - #[test] - fn load_received_follow_state_returns_new_relationship_from_embedded_actor() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let state = store - .load_received_follow_state( - &follow(Reference::object(actor("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert!(!state.already_processed); - assert_eq!(state.relationship, FollowRelationship::NotFollowing); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }) - ); - } - - #[test] - fn load_received_follow_state_returns_existing_relationship_from_storage() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - )) - .expect("apply follower decision"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }) - ); - } - - #[test] - fn load_received_follow_state_prefers_embedded_actor_endpoints() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - None, - )) - .expect("apply follower decision"); - - let mut updated_actor = actor("https://remote.example/users/bob"); - updated_actor.inbox = iri("https://remote.example/users/bob/updated-inbox"); - updated_actor.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - }); - let state = store - .load_received_follow_state( - &follow(Reference::object(updated_actor)), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/updated-inbox")), - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - }) - ); - } - - #[test] - fn load_received_follow_state_does_not_reuse_stale_shared_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/old-inbox"), - Some("https://remote.example/old-shared-inbox"), - )) - .expect("apply follower decision"); - - let mut updated_actor = actor("https://remote.example/users/bob"); - updated_actor.inbox = iri("https://remote.example/users/bob/current-inbox"); - updated_actor.endpoints = None; - - let state = store - .load_received_follow_state( - &follow(Reference::object(updated_actor)), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/current-inbox")), - shared_inbox: None, - }) - ); - } - - #[test] - fn load_received_follow_state_reports_processed_activity() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - "INSERT INTO processed_inbox_activities (activity_id) VALUES (?1)", - ["https://remote.example/activities/follow-1"], - ) - .expect("insert processed inbox activity"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert!(state.already_processed); - } - - #[test] - fn list_followers_returns_stored_followers() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply follower decision"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: None, - shared_inbox: None, - }] - ); - } - - #[test] - fn list_followers_returns_follower_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - )) - .expect("apply follower decision"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }] - ); - } - - #[test] - fn list_followers_returns_only_followers_for_actor() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: None, - shared_inbox: None, - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/eve"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: None, - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: None, - shared_inbox: None, - }] - ); - } - - #[test] - fn list_follower_recipients_returns_followers_with_inboxes() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: None, - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/inbox"), - shared_inbox: Some(iri("https://remote.example/inbox")), - }] - ); - } - - #[test] - fn list_follower_recipients_returns_only_recipients_for_actor() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/eve"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: Some(iri("https://remote.example/users/carol/inbox")), - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/inbox"), - shared_inbox: None, - }] - ); - } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs deleted file mode 100644 index 23d86f2..0000000 --- a/crates/feder-runtime-server/src/webfinger.rs +++ /dev/null @@ -1,157 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Query, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, -}; -use serde::{Deserialize, Serialize}; - -use crate::app::AppState; - -#[derive(Deserialize)] -pub struct WebFingerQuery { - resource: Option, -} - -#[derive(Serialize)] -pub struct WebFingerResponse { - subject: String, - aliases: Vec, - links: Vec, -} - -#[derive(Serialize)] -pub struct WebFingerLink { - rel: &'static str, - #[serde(rename = "type")] - media_type: &'static str, - href: String, -} - -pub async fn webfinger( - State(state): State, - Query(query): Query, -) -> Result { - let Some(resource) = query.resource else { - return Err(StatusCode::BAD_REQUEST); - }; - - let expected = format!("acct:{}@{}", state.username, state.handle_host); - if resource != expected { - return Err(StatusCode::NOT_FOUND); - } - - let actor_id = state.local_actor.id.to_string(); - - Ok(( - [(header::CONTENT_TYPE, "application/jrd+json")], - Json(WebFingerResponse { - subject: resource, - aliases: vec![actor_id.clone()], - links: vec![WebFingerLink { - rel: "self", - media_type: "application/activity+json", - href: actor_id, - }], - }), - ) - .into_response()) -} - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; - - #[tokio::test] - async fn returns_webfinger_descriptor_for_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri(WEBFINGER_PATH) - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/jrd+json" - ); - - let body = to_bytes(response.into_body(), 1024) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); - assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["links"][0]["rel"], "self"); - assert_eq!(json["links"][0]["type"], "application/activity+json"); - assert_eq!( - json["links"][0]["href"], - "http://127.0.0.1:3000/users/alice" - ); - } - - #[tokio::test] - async fn rejects_missing_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn rejects_non_local_actor_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-server/Cargo.toml b/crates/feder-server/Cargo.toml new file mode 100644 index 0000000..563ea8b --- /dev/null +++ b/crates/feder-server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "feder-server" +description = "ActivityPub server runtime for Feder on standard operating systems." +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +axum = "=0.8" +feder-vocab.workspace = true +httpdate = "1" +mime = "0.3.17" +percent-encoding = "2.3.2" +rand_core.workspace = true +feder-core = { workspace = true, features = ["http-signatures"] } +reqwest = { + version = "0.13.1", + default-features = false, + features = ["rustls"], +} +rusqlite = { version = "0.40.1", features = ["bundled"] } +serde.workspace = true +serde_json.workspace = true +thiserror = "2.0.19" +url = "2" +ipnet = "2.11.0" +tokio = { version = "1", features = ["net"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } +tower.workspace = true + +[lints] +workspace = true diff --git a/crates/feder-server/src/actor.rs b/crates/feder-server/src/actor.rs new file mode 100644 index 0000000..e84a677 --- /dev/null +++ b/crates/feder-server/src/actor.rs @@ -0,0 +1,282 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::ActorDispatcher; +use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; +use reqwest::{ + Client, StatusCode as HttpStatusCode, Url, + header::{ACCEPT, CONTENT_TYPE}, +}; +use serde::Deserialize; + +use crate::{FederServer, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; + +const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; +const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + +impl ActorDispatcher for FederServer +where + A: ActorDispatcher, +{ + type Error = A::Error; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + self.actors().get_actor(identifier) + } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + self.actors().get_actor_by_id(actor_id) + } +} + +pub async fn actor( + State(server): State>>, + Path(identifier): Path, + headers: HeaderMap, +) -> Result +where + A: ActorDispatcher, +{ + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + let actor = server + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(actor), + ) + .into_response()) +} + +#[derive(Clone, Debug)] +pub struct ActorResolver { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActorResolver { + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(ActorResolveError::BuildClient)?; + Ok(Self { + client, + address_policy, + }) + } + + pub async fn resolve_reference( + &self, + reference: &mut Reference, + ) -> Result<(), ActorResolveError> { + let Reference::Id(actor_id) = reference else { + return Ok(()); + }; + let actor_id = actor_id.clone(); + let actor = self.resolve(&actor_id).await?; + *reference = Reference::object(actor); + Ok(()) + } + + pub async fn resolve(&self, actor_id: &Iri) -> Result { + let body = self.fetch_document(actor_id).await?; + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } + + pub(crate) async fn resolve_key( + &self, + key_id: &Iri, + ) -> Result { + let body = self.fetch_document(key_id).await?; + if let Ok(key) = serde_json::from_slice::(&body) + && key.id == *key_id + { + return Ok(key); + } + if let Ok(document) = serde_json::from_slice::(&body) + && let Some(Reference::Object(key)) = document.public_key + && key.id == *key_id + { + return Ok(*key); + } + + Err(ActorResolveError::KeyNotFound(key_id.to_string())) + } + + async fn fetch_document(&self, resource_id: &Iri) -> Result, ActorResolveError> { + let url = Url::parse(resource_id.as_str()) + .map_err(|_| ActorResolveError::InvalidResourceId(resource_id.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(ActorResolveError::InvalidResourceId( + resource_id.to_string(), + )); + } + url::validate_literal_host(&url, self.address_policy).map_err(|address| { + ActorResolveError::PrivateResourceAddress { + resource: resource_id.to_string(), + address, + } + })?; + + let mut response = self + .client + .get(url) + .header(ACCEPT, ACTIVITYPUB_ACCEPT) + .send() + .await + .map_err(ActorResolveError::Request)?; + if !response.status().is_success() { + return Err(ActorResolveError::UnsuccessfulStatus { + resource: resource_id.to_string(), + status: response.status(), + }); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !is_activitypub_content_type(content_type) { + return Err(ActorResolveError::UnsupportedContentType( + content_type.to_string(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_ACTOR_BODY_SIZE as u64) + { + return Err(ActorResolveError::ResponseTooLarge); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(ActorResolveError::Request)? { + if body.len().saturating_add(chunk.len()) > MAX_ACTOR_BODY_SIZE { + return Err(ActorResolveError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + Ok(body) + } +} + +fn is_activitypub_content_type(content_type: &str) -> bool { + let media_type = content_type + .split_once(';') + .map_or(content_type, |(media_type, _)| media_type) + .trim(); + media_type.eq_ignore_ascii_case("application/activity+json") + || media_type.eq_ignore_ascii_case("application/ld+json") +} + +#[derive(Deserialize)] +struct ActorDocument { + #[serde(rename = "type")] + kind: ActorType, + id: Iri, + inbox: Iri, + outbox: Iri, + followers: Option, + #[serde(rename = "preferredUsername")] + preferred_username: Option, + name: Option, + endpoints: Option, + #[serde(rename = "publicKey")] + public_key: Option>, +} + +impl ActorDocument { + fn into_actor(self) -> Actor { + Actor { + context: None, + kind: self.kind, + id: self.id, + inbox: self.inbox, + outbox: self.outbox, + followers: self.followers, + preferred_username: self.preferred_username, + name: self.name, + endpoints: self.endpoints, + public_key: self.public_key, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ActorResolveError { + #[error("failed to build actor resolution HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("invalid remote ActivityPub resource ID: {0}")] + InvalidResourceId(String), + + #[error("remote ActivityPub resource {resource} uses non-public address {address}")] + PrivateResourceAddress { + resource: String, + address: std::net::IpAddr, + }, + + #[error("failed to fetch remote ActivityPub resource")] + Request(#[source] reqwest::Error), + + #[error("fetching remote ActivityPub resource {resource} returned {status}")] + UnsuccessfulStatus { + resource: String, + status: HttpStatusCode, + }, + + #[error("remote actor response has unsupported content type: {0}")] + UnsupportedContentType(String), + + #[error("remote actor response exceeds size limit")] + ResponseTooLarge, + + #[error("failed to deserialize remote actor")] + Deserialize(#[source] serde_json::Error), + + #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] + ActorIdMismatch { requested: String, returned: String }, + + #[error("remote ActivityPub document does not contain key {0}")] + KeyNotFound(String), +} diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-server/src/config.rs similarity index 71% rename from crates/feder-runtime-server/src/error.rs rename to crates/feder-server/src/config.rs index a031fa3..d645672 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-server/src/config.rs @@ -13,14 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("failed to bind server socket")] - Bind(#[source] std::io::Error), +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundAddressPolicy { + /// Allows only publicly routable destination addresses. + #[default] + PublicOnly, - #[error("server failed")] - Serve(#[source] std::io::Error), - - #[error("storage failed")] - Storage(#[from] crate::storage::StoreError), + /// Allows private and special-use destinations. This disables SSRF protection. + AllowPrivateAddress, } diff --git a/crates/feder-server/src/follow.rs b/crates/feder-server/src/follow.rs new file mode 100644 index 0000000..443e9bf --- /dev/null +++ b/crates/feder-server/src/follow.rs @@ -0,0 +1,90 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_core::{ActorDispatcher, follow::create_follow, storage::ServerStorage}; +use feder_vocab::{Follow, Iri}; + +use crate::{ActorResolveError, FederServer, send::SendError}; + +impl FederServer +where + A: ActorDispatcher, + S: ServerStorage, +{ + /// Creates, persists, and delivers a Follow initiated by a local actor. + /// + /// The pending relationship is persisted before delivery so applications + /// can retain the intent when delivery fails and implement retries. + pub async fn follow_actor( + &self, + local_actor_id: &Iri, + remote_actor_id: &Iri, + follow_id: Iri, + ) -> Result> { + let local_actor = self + .actors() + .get_actor_by_id(local_actor_id) + .map_err(FollowActorError::ActorDispatcher)? + .ok_or_else(|| FollowActorError::LocalActorNotFound(local_actor_id.clone()))?; + let remote_actor = self + .resolver() + .resolve(remote_actor_id) + .await + .map_err(FollowActorError::ActorResolver)?; + let outcome = create_follow(&local_actor, &remote_actor, follow_id); + + self.storage() + .store_pending_follow(&outcome.relationship) + .map_err(FollowActorError::Storage)?; + let key_pair = self + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(FollowActorError::Storage)? + .ok_or_else(|| FollowActorError::MissingActorKey(local_actor.id.clone()))?; + let inbox = remote_actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()) + .unwrap_or(&remote_actor.inbox); + + self.sender() + .send_activity(&local_actor, &key_pair, &outcome.activity, inbox) + .await + .map_err(FollowActorError::ActivitySender)?; + + Ok(outcome.activity) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FollowActorError { + #[error("actor dispatcher failed")] + ActorDispatcher(A), + + #[error("local actor not found: {0}")] + LocalActorNotFound(Iri), + + #[error("failed to resolve remote actor")] + ActorResolver(#[source] ActorResolveError), + + #[error("server storage failed")] + Storage(S), + + #[error("local actor has no stored signing key: {0}")] + MissingActorKey(Iri), + + #[error("failed to send Follow activity")] + ActivitySender(#[source] SendError), +} diff --git a/crates/feder-server/src/followers.rs b/crates/feder-server/src/followers.rs new file mode 100644 index 0000000..cc38b5c --- /dev/null +++ b/crates/feder-server/src/followers.rs @@ -0,0 +1,67 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::{ActorDispatcher, storage::ServerStorage}; +use feder_vocab::OrderedCollection; + +use crate::{FederServer, negotiation::accepts_activitypub}; + +pub async fn followers( + State(server): State>>, + Path(identifier): Path, + headers: HeaderMap, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + let followers = server + .storage() + .list_followers(&actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let total_items = + u64::try_from(followers.len()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let collection_id = actor + .followers + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let collection = OrderedCollection::new(collection_id, total_items, followers); + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(collection), + ) + .into_response()) +} diff --git a/crates/feder-server/src/inbox.rs b/crates/feder-server/src/inbox.rs new file mode 100644 index 0000000..e6ccff5 --- /dev/null +++ b/crates/feder-server/src/inbox.rs @@ -0,0 +1,622 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_TYPE, HOST}, + uri::Authority, + }, + response::{IntoResponse, Response}, +}; +use feder_core::{ + ActorDispatcher, + follow::{ + AcceptFollowError, FollowError, PendingFollow, receive_accept_follow, receive_follow, + }, + key::{create_sha256_digest_header, verify_draft_cavage}, + storage::ServerStorage, + undo::{UndoFollowError, receive_undo_follow}, +}; +use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference, Undo}; +use mime::Mime; +use serde_json::{Value, from_slice, from_value}; + +use crate::{ActorResolver, FederServer}; + +const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); +const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); +const ACTIVITYPUB_CONTENT_TYPES: &[&str] = &["application/activity+json", "application/ld+json"]; + +// FIXME: Remove this policy once the reference example sends signed Follow +// requests. The built-in inbox should always verify requests; applications +// that need different authentication can build an inbox and call core directly. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum InboxAuthPolicy { + AllowUnsignedInsecureDev, + #[default] + RequireSigned, +} + +struct InboxRequest { + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +} + +pub async fn inbox( + State(server): State>>, + Path(identifier): Path, + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let local_actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let expected_inbox = local_actor.inbox.clone(); + let (request, value) = parse_inbox_request(headers, method, uri, body)?; + + receive_activity(&server, local_actor, expected_inbox, request, value, None).await +} + +pub async fn shared_inbox( + State(server): State>>, + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let (request, value) = parse_inbox_request(headers, method, uri, body)?; + let Some((target_id, pending_follow)) = shared_inbox_target(server.storage(), &value)? else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + let Some(local_actor) = server + .actors() + .get_actor_by_id(&target_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + let Some(expected_inbox) = local_actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.clone()) + else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + + receive_activity( + &server, + local_actor, + expected_inbox, + request, + value, + pending_follow, + ) + .await +} + +fn parse_inbox_request( + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result<(InboxRequest, Value), StatusCode> { + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if !content_type + .is_some_and(|media_type| ACTIVITYPUB_CONTENT_TYPES.contains(&media_type.essence_str())) + { + return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + let request = InboxRequest { + headers, + method, + uri, + body, + }; + let value: Value = from_slice(&request.body).map_err(|_| StatusCode::BAD_REQUEST)?; + + Ok((request, value)) +} + +async fn receive_activity( + server: &FederServer, + local_actor: Actor, + expected_inbox: Iri, + request: InboxRequest, + value: Value, + pending_follow: Option, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let activity_actor_id = activity_actor_id(&value); + let verified_actor = match server.inbox_auth_policy() { + InboxAuthPolicy::AllowUnsignedInsecureDev => None, + InboxAuthPolicy::RequireSigned => Some( + verify_signed_request( + server.resolver(), + &request, + activity_actor_id.as_ref().ok_or(StatusCode::UNAUTHORIZED)?, + &expected_inbox, + ) + .await?, + ), + }; + + match value.get("type").and_then(Value::as_str) { + Some("Follow") => {} + Some("Accept") => { + let accept: Accept = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_actor_reference(server.resolver(), &accept.actor).await?, + }; + let follow_activity = follow_reference_id(&accept.object); + let pending = match pending_follow { + Some(pending) if pending.follow_activity == *follow_activity => pending, + Some(_) => return Ok(StatusCode::ACCEPTED.into_response()), + None => match server + .storage() + .load_pending_follow(follow_activity) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + Some(pending) => pending, + None => return Ok(StatusCode::ACCEPTED.into_response()), + }, + }; + match receive_accept_follow(&local_actor, &remote_actor, &pending, accept) { + Ok(_) => {} + Err(AcceptFollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + Err( + AcceptFollowError::WrongFollow + | AcceptFollowError::WrongFollowActor + | AcceptFollowError::WrongFollowObject + | AcceptFollowError::WrongLocalActor, + ) => return Ok(StatusCode::ACCEPTED.into_response()), + } + + server + .storage() + .confirm_pending_follow(&pending) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + return Ok(StatusCode::ACCEPTED.into_response()); + } + Some("Undo") => { + let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_actor_reference(server.resolver(), &undo.actor).await?, + }; + let outcome = match receive_undo_follow(&local_actor, &remote_actor, undo) { + Ok(outcome) => outcome, + Err(UndoFollowError::LinkedFollow | UndoFollowError::WrongObject) => { + return Ok(StatusCode::ACCEPTED.into_response()); + } + Err(UndoFollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + }; + + server + .storage() + .remove_follower(&outcome.follower, &outcome.following) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + return Ok(StatusCode::ACCEPTED.into_response()); + } + _ => return Ok(StatusCode::ACCEPTED.into_response()), + } + + let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_actor_reference(server.resolver(), &follow.actor).await?, + }; + let accept_id = accept_id_for_follow(&local_actor.id, &follow.id)?; + let outcome = match receive_follow(&local_actor, &remote_actor, follow, accept_id) { + Ok(outcome) => outcome, + Err(FollowError::WrongObject) => return Ok(StatusCode::ACCEPTED.into_response()), + Err(FollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + }; + + server + .storage() + .store_follower(&outcome.follower, &outcome.following) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let key_pair = server + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + server + .sender() + .send_activity( + &local_actor, + &key_pair, + &outcome.accept, + &outcome.recipient_inbox, + ) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + Ok(StatusCode::ACCEPTED.into_response()) +} + +async fn resolve_actor_reference( + resolver: &ActorResolver, + actor: &Reference, +) -> Result { + match actor { + Reference::Object(actor) => Ok((**actor).clone()), + Reference::Id(actor_id) => resolver + .resolve(actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY), + } +} + +async fn verify_signed_request( + resolver: &ActorResolver, + request: &InboxRequest, + activity_actor_id: &Iri, + expected_inbox: &Iri, +) -> Result { + let signature_header = request + .headers + .get("signature") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let signature = parse_signature_header(signature_header).ok_or(StatusCode::UNAUTHORIZED)?; + if signature.algorithm != "rsa-sha256" + || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") + { + return Err(StatusCode::UNAUTHORIZED); + } + + let mut seen_headers = HashSet::new(); + let mut signed_headers = Vec::new(); + for name in signature.signed_headers.iter().skip(1) { + if name.starts_with('(') || !seen_headers.insert(name.as_str()) { + return Err(StatusCode::UNAUTHORIZED); + } + let values = request.headers.get_all(name).iter().collect::>(); + let [value] = values.as_slice() else { + return Err(StatusCode::UNAUTHORIZED); + }; + let value = value.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?; + signed_headers.push((name.as_str(), value)); + } + if !["host", "date", "digest"] + .iter() + .all(|required| seen_headers.contains(required)) + { + return Err(StatusCode::UNAUTHORIZED); + } + + verify_request_host(&request.headers, expected_inbox)?; + verify_request_date(&request.headers)?; + verify_request_digest(&request.headers, &request.body)?; + + let key_id: Iri = signature + .key_id + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let public_key = resolver + .resolve_key(&key_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + if public_key.id != key_id || public_key.owner != *activity_actor_id { + return Err(StatusCode::UNAUTHORIZED); + } + + let request_target = request + .uri + .path_and_query() + .map_or(request.uri.path(), |value| value.as_str()); + verify_draft_cavage( + &public_key.public_key_pem, + request.method.as_str(), + request_target, + &signed_headers, + &signature.signature, + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + let actor = resolver + .resolve(activity_actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + if actor.id != *activity_actor_id || !actor_owns_key(&actor, &public_key) { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(actor) +} + +fn actor_owns_key(actor: &Actor, key: &CryptographicKey) -> bool { + match actor.public_key.as_ref() { + Some(Reference::Id(advertised_key_id)) => advertised_key_id == &key.id, + Some(Reference::Object(advertised_key)) => { + advertised_key.id == key.id + && advertised_key.owner == actor.id + && advertised_key.public_key_pem == key.public_key_pem + } + None => false, + } +} + +fn accept_id_for_follow(local_actor_id: &Iri, follow_id: &Iri) -> Result { + let encoded_follow_id = percent_encoding::utf8_percent_encode( + follow_id.as_str(), + percent_encoding::NON_ALPHANUMERIC, + ); + format!("{local_actor_id}#accepts/{encoded_follow_id}") + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn activity_actor_id(value: &Value) -> Option { + let actor = value.get("actor")?; + actor + .as_str() + .or_else(|| actor.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + +fn activity_target_id(value: &Value) -> Option { + let target = match value.get("type").and_then(Value::as_str) { + Some("Follow") => value.get("object")?, + Some("Undo") => value.get("object")?.get("object")?, + _ => return None, + }; + + target + .as_str() + .or_else(|| target.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + +fn shared_inbox_target( + storage: &S, + value: &Value, +) -> Result)>, StatusCode> +where + S: ServerStorage, +{ + if value.get("type").and_then(Value::as_str) == Some("Accept") { + let Some(follow_activity) = value.get("object").and_then(value_reference_id) else { + return Ok(None); + }; + let pending = storage + .load_pending_follow(&follow_activity) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + return Ok(pending.map(|pending| { + let local_actor = pending.local_actor.clone(); + (local_actor, Some(pending)) + })); + } + + Ok(activity_target_id(value).map(|target| (target, None))) +} + +fn value_reference_id(value: &Value) -> Option { + value + .as_str() + .or_else(|| value.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + +fn follow_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(follow) => &follow.id, + } +} + +fn verify_request_host(headers: &HeaderMap, inbox: &Iri) -> Result<(), StatusCode> { + let signed_host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::UNAUTHORIZED)?; + let inbox_uri = inbox + .as_str() + .parse::() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let expected_host = inbox_uri + .authority() + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let default_port = match inbox_uri.scheme_str() { + Some(scheme) if scheme.eq_ignore_ascii_case("http") => Some(80), + Some(scheme) if scheme.eq_ignore_ascii_case("https") => Some(443), + _ => None, + }; + let signed_port = effective_port(&signed_host, default_port).ok_or(StatusCode::UNAUTHORIZED)?; + let expected_port = + effective_port(expected_host, default_port).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + if signed_host + .host() + .eq_ignore_ascii_case(expected_host.host()) + && signed_port == expected_port + { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +fn effective_port(authority: &Authority, default_port: Option) -> Option> { + let suffix = authority.as_str().get(authority.host().len()..)?; + if suffix.is_empty() { + Some(default_port) + } else if suffix.starts_with(':') { + authority.port_u16().map(Some) + } else { + None + } +} + +fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { + let date = headers + .get("date") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED) + .and_then(|value| httpdate::parse_http_date(value).map_err(|_| StatusCode::UNAUTHORIZED))?; + let now = SystemTime::now(); + if now + .duration_since(date) + .is_ok_and(|age| age > MAX_SIGNATURE_AGE) + || date + .duration_since(now) + .is_ok_and(|skew| skew > MAX_CLOCK_SKEW) + { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(()) +} + +fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> { + let digest = headers + .get("digest") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected = create_sha256_digest_header(body); + let matches = digest.split(',').any(|entry| { + entry + .trim() + .split_once('=') + .is_some_and(|(algorithm, value)| { + algorithm.eq_ignore_ascii_case("sha-256") + && expected + .split_once('=') + .is_some_and(|(_, expected)| value == expected) + }) + }); + if matches { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +struct ParsedSignature { + key_id: String, + algorithm: String, + signed_headers: Vec, + signature: String, +} + +fn parse_signature_header(header: &str) -> Option { + let mut parameters = BTreeMap::new(); + let mut remaining = header; + while !remaining.trim_start().is_empty() { + remaining = remaining.trim_start(); + let equals = remaining.find('=')?; + let name = remaining[..equals].trim(); + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return None; + } + remaining = &remaining[equals + 1..]; + let (value, rest) = parse_quoted_parameter(remaining.trim_start())?; + if parameters + .insert(name.to_ascii_lowercase(), value) + .is_some() + { + return None; + } + remaining = rest.trim_start(); + if remaining.is_empty() { + break; + } + remaining = remaining.strip_prefix(',')?; + } + + let key_id = parameters.remove("keyid")?; + let algorithm = parameters.remove("algorithm")?; + let signed_headers = parameters + .remove("headers")? + .split_ascii_whitespace() + .map(str::to_ascii_lowercase) + .collect::>(); + let signature = parameters.remove("signature")?; + if key_id.is_empty() || signed_headers.is_empty() || signature.is_empty() { + return None; + } + Some(ParsedSignature { + key_id, + algorithm: algorithm.to_ascii_lowercase(), + signed_headers, + signature, + }) +} + +fn parse_quoted_parameter(input: &str) -> Option<(String, &str)> { + let input = input.strip_prefix('"')?; + let mut value = String::new(); + let mut escaped = false; + for (index, character) in input.char_indices() { + if escaped { + value.push(character); + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + return Some((value, &input[index + character.len_utf8()..])); + } else if character.is_control() { + return None; + } else { + value.push(character); + } + } + None +} diff --git a/crates/feder-server/src/lib.rs b/crates/feder-server/src/lib.rs new file mode 100644 index 0000000..52a82f5 --- /dev/null +++ b/crates/feder-server/src/lib.rs @@ -0,0 +1,165 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! ActivityPub server runtime for Feder on standard operating systems. +//! +//! This crate connects [`feder_core`] protocol decisions to Axum, SQLite, +//! remote actor resolution, HTTP signatures, and activity delivery. +pub mod actor; +pub mod config; +pub mod follow; +pub mod followers; +pub mod inbox; +pub mod negotiation; +pub mod note; +pub mod object; +pub mod send; +pub mod storage; +pub mod url; +pub mod webfinger; + +use std::sync::Arc; + +pub use actor::{ActorResolveError, ActorResolver}; +use axum::{ + Router, + extract::DefaultBodyLimit, + routing::{get, post}, +}; +pub use config::OutboundAddressPolicy; +pub use feder_core::ActorDispatcher; +use feder_core::storage::{NoteStore, ServerStorage}; +pub use inbox::InboxAuthPolicy; + +use crate::send::{ActivitySender, SendError}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to bind server socket")] + Bind(#[source] std::io::Error), + + #[error("server failed")] + Serve(#[source] std::io::Error), + + #[error("failed to construct activity sender")] + ActivitySender(#[from] SendError), + + #[error("failed to construct actor resolver")] + ActorResolver(#[from] ActorResolveError), +} + +pub struct FederServer { + actors: A, + storage: S, + handle_host: String, + resolver: ActorResolver, + sender: ActivitySender, + inbox_auth_policy: InboxAuthPolicy, +} + +impl FederServer { + pub fn new(actors: A, storage: S, handle_host: impl Into) -> Result { + let policy = OutboundAddressPolicy::PublicOnly; + let resolver = ActorResolver::new(policy)?; + let sender = ActivitySender::new(policy)?; + Ok(Self { + actors, + storage, + handle_host: handle_host.into(), + resolver, + sender, + inbox_auth_policy: InboxAuthPolicy::RequireSigned, + }) + } + + // for development + pub fn with_outbound_address_policy( + actors: A, + storage: S, + handle_host: impl Into, + policy: OutboundAddressPolicy, + ) -> Result { + let resolver = ActorResolver::new(policy)?; + let sender = ActivitySender::new(policy)?; + Ok(Self { + actors, + storage, + handle_host: handle_host.into(), + resolver, + sender, + inbox_auth_policy: InboxAuthPolicy::RequireSigned, + }) + } + + #[must_use] + pub fn with_inbox_auth_policy(mut self, inbox_auth_policy: InboxAuthPolicy) -> Self { + self.inbox_auth_policy = inbox_auth_policy; + self + } + + pub(crate) fn actors(&self) -> &A { + &self.actors + } + + pub(crate) fn storage(&self) -> &S { + &self.storage + } + + pub(crate) fn handle_host(&self) -> &str { + &self.handle_host + } + + pub(crate) fn resolver(&self) -> &ActorResolver { + &self.resolver + } + + pub(crate) fn sender(&self) -> &ActivitySender { + &self.sender + } + + pub(crate) fn inbox_auth_policy(&self) -> InboxAuthPolicy { + self.inbox_auth_policy + } +} + +pub fn build_router(server: FederServer) -> Router +where + A: ActorDispatcher + Send + Sync + 'static, + S: NoteStore + ServerStorage + Send + Sync + 'static, +{ + build_router_with_state(Arc::new(server)) +} + +pub fn build_router_with_state(server: Arc>) -> Router +where + A: ActorDispatcher + Send + Sync + 'static, + S: NoteStore + ServerStorage + Send + Sync + 'static, +{ + Router::new() + .route("/users/{identifier}", get(actor::actor::)) + .route( + "/users/{identifier}/followers", + get(followers::followers::), + ) + .route( + "/users/{identifier}/posts/{post_id}", + get(object::note::), + ) + .route("/.well-known/webfinger", get(webfinger::webfinger::)) + .route("/users/{identifier}/inbox", post(inbox::inbox::)) + .route("/inbox", post(inbox::shared_inbox::)) + .layer(DefaultBodyLimit::max(1_048_576)) + .with_state(server) +} diff --git a/crates/feder-server/src/negotiation.rs b/crates/feder-server/src/negotiation.rs new file mode 100644 index 0000000..143905f --- /dev/null +++ b/crates/feder-server/src/negotiation.rs @@ -0,0 +1,104 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::{HeaderMap, header::ACCEPT}; +use mime::Mime; + +const ACTIVITYPUB_MEDIA_TYPES: &[&str] = &[ + "application/activity+json", + "application/ld+json", + "application/json", +]; +const HTML_MEDIA_TYPES: &[&str] = &["text/html", "application/xhtml+xml"]; + +struct MediaRange { + media_type: Mime, + quality: u16, + order: usize, +} + +#[derive(Clone, Copy)] +struct Preference { + quality: u16, + order: usize, +} + +pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { + let ranges = headers + .get_all(ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().parse::().ok()) + .enumerate() + .filter_map(|(order, media_range)| { + let quality = media_range + .get_param("q") + .map_or(Some(1000), |value| parse_quality(value.as_str()))?; + (quality > 0).then_some(MediaRange { + media_type: media_range, + quality, + order, + }) + }) + .collect::>(); + + let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); + let html = preferred(HTML_MEDIA_TYPES, &ranges); + + match (activitypub, html) { + (Some(activitypub), Some(html)) => prefers(activitypub, html), + (Some(_), None) => true, + _ => false, + } +} + +fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { + ranges + .iter() + .filter(|range| media_types.contains(&range.media_type.essence_str())) + .map(|range| Preference { + quality: range.quality, + order: range.order, + }) + .reduce(|current, candidate| { + if prefers(candidate, current) { + candidate + } else { + current + } + }) +} + +fn prefers(left: Preference, right: Preference) -> bool { + left.quality > right.quality || (left.quality == right.quality && left.order < right.order) +} + +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + match whole { + "0" => { + let padding = 3 - fraction.len(); + let fraction = fraction.parse::().unwrap_or(0); + Some(fraction * 10_u16.pow(u32::try_from(padding).ok()?)) + } + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + _ => None, + } +} diff --git a/crates/feder-server/src/note.rs b/crates/feder-server/src/note.rs new file mode 100644 index 0000000..b516d16 --- /dev/null +++ b/crates/feder-server/src/note.rs @@ -0,0 +1,158 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::HashSet; + +use feder_core::{ + ActorDispatcher, + note::{CreateNoteInput, CreateNoteOutcome, NoteRecipient, create_note}, + storage::{FollowerDeliveryStore, NoteStore}, +}; +use feder_vocab::Iri; + +use crate::{ActorResolveError, FederServer, send::SendError}; + +impl FederServer +where + A: ActorDispatcher, + S: FollowerDeliveryStore + NoteStore, +{ + /// Constructs, persists, and delivers a local Note without retaining state. + /// + /// Persistence occurs before delivery. Every independent recipient is + /// attempted even if another resolution or delivery fails. + pub async fn create_note( + &self, + local_actor_id: &Iri, + input: CreateNoteInput, + ) -> Result> { + let local_actor = self + .actors() + .get_actor_by_id(local_actor_id) + .map_err(CreateNoteError::ActorDispatcher)? + .ok_or_else(|| CreateNoteError::LocalActorNotFound(local_actor_id.clone()))?; + let outcome = create_note(&local_actor, input); + + self.storage() + .store_note(&outcome.note) + .map_err(CreateNoteError::Storage)?; + + let (inboxes, actor_resolve_error) = self + .resolve_note_recipients(&outcome.recipients) + .await + .map_err(CreateNoteError::Storage)?; + if inboxes.is_empty() { + if let Some(error) = actor_resolve_error { + return Err(CreateNoteError::ActorResolver(error)); + } + return Ok(outcome); + } + + let key_pair = self + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(CreateNoteError::Storage)? + .ok_or_else(|| CreateNoteError::MissingActorKey(local_actor.id.clone()))?; + let mut first_send_error = None; + for inbox in inboxes { + if let Err(error) = self + .sender() + .send_activity(&local_actor, &key_pair, &outcome.activity, &inbox) + .await + && first_send_error.is_none() + { + first_send_error = Some(error); + } + } + + if let Some(error) = first_send_error { + Err(CreateNoteError::ActivitySender(error)) + } else if let Some(error) = actor_resolve_error { + Err(CreateNoteError::ActorResolver(error)) + } else { + Ok(outcome) + } + } + + async fn resolve_note_recipients( + &self, + recipients: &[NoteRecipient], + ) -> Result<(Vec, Option), S::Error> { + let mut inboxes = Vec::new(); + let mut covered_actor_ids = HashSet::new(); + let mut seen_inboxes = HashSet::new(); + let mut first_actor_resolve_error = None; + + for recipient in recipients { + let NoteRecipient::Followers(local_actor_id) = recipient else { + continue; + }; + for target in self + .storage() + .list_follower_delivery_targets(local_actor_id)? + { + covered_actor_ids.insert(target.actor_id); + let inbox = target.shared_inbox.unwrap_or(target.inbox); + if seen_inboxes.insert(inbox.clone()) { + inboxes.push(inbox); + } + } + } + + for recipient in recipients { + let NoteRecipient::Actor(actor_id) = recipient else { + continue; + }; + if covered_actor_ids.contains(actor_id) { + continue; + } + match self.resolver().resolve(actor_id).await { + Ok(actor) => { + covered_actor_ids.insert(actor.id.clone()); + if seen_inboxes.insert(actor.inbox.clone()) { + inboxes.push(actor.inbox); + } + } + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); + } + Err(_) => {} + } + } + + Ok((inboxes, first_actor_resolve_error)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CreateNoteError { + #[error("actor dispatcher failed")] + ActorDispatcher(A), + + #[error("local actor not found: {0}")] + LocalActorNotFound(Iri), + + #[error("note or follower storage failed")] + Storage(S), + + #[error("failed to resolve a Note recipient")] + ActorResolver(#[source] ActorResolveError), + + #[error("local actor has no stored signing key: {0}")] + MissingActorKey(Iri), + + #[error("failed to send Create activity")] + ActivitySender(#[source] SendError), +} diff --git a/crates/feder-server/src/object.rs b/crates/feder-server/src/object.rs new file mode 100644 index 0000000..4101cf7 --- /dev/null +++ b/crates/feder-server/src/object.rs @@ -0,0 +1,79 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::{ActorDispatcher, note::is_public_note, storage::NoteStore}; +use feder_vocab::Iri; + +use crate::{FederServer, negotiation::accepts_activitypub}; + +pub async fn note( + State(server): State>>, + Path((identifier, post_id)): Path<(String, String)>, + headers: HeaderMap, +) -> Result +where + A: ActorDispatcher, + S: NoteStore, +{ + let actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let note_id = note_id(&actor.id, &post_id)?; + let note = server + .storage() + .load_note(¬e_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + if !is_public_note(¬e) { + return Err(StatusCode::NOT_FOUND); + } + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(note), + ) + .into_response()) +} + +fn note_id(actor_id: &Iri, post_id: &str) -> Result { + let mut url = + url::Url::parse(actor_id.as_str()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + url.set_query(None); + url.set_fragment(None); + url.path_segments_mut() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .pop_if_empty() + .push("posts") + .push(post_id); + url.as_str() + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/crates/feder-server/src/send.rs b/crates/feder-server/src/send.rs new file mode 100644 index 0000000..960464a --- /dev/null +++ b/crates/feder-server/src/send.rs @@ -0,0 +1,162 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::time::SystemTime; + +use feder_core::key::{ + ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, +}; +use feder_vocab::{Actor, Iri, Reference}; +use reqwest::{ + Client, StatusCode, Url, + header::{CONTENT_TYPE, DATE, HOST}, +}; +use serde::Serialize; + +use crate::{config::OutboundAddressPolicy, url}; + +#[derive(Clone, Debug)] +pub struct ActivitySender { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActivitySender { + /// Creates a signed ActivityPub HTTP sender. + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; + + Ok(Self { + client, + address_policy, + }) + } + + pub async fn send_activity( + &self, + local_actor: &Actor, + key_pair: &ActorKeyPair, + activity: &T, + inbox: &Iri, + ) -> Result<(), SendError> + where + T: Serialize + ?Sized, + { + let key_id = match local_actor.public_key.as_ref() { + Some(Reference::Id(key_id)) => key_id, + Some(Reference::Object(key)) => { + if key.owner != local_actor.id || key.public_key_pem != key_pair.public_key_pem() { + return Err(SendError::ActorKeyMismatch(local_actor.id.to_string())); + } + &key.id + } + None => return Err(SendError::MissingActorKey(local_actor.id.to_string())), + }; + let body = serde_json::to_vec(activity).map_err(SendError::Serialize)?; + let url = + Url::parse(inbox.as_str()).map_err(|_| SendError::InvalidInbox(inbox.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(SendError::InvalidInbox(inbox.to_string())); + } + crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { + SendError::PrivateInboxAddress { + inbox: inbox.to_string(), + address, + } + })?; + let mut host = url + .host() + .ok_or_else(|| SendError::InvalidInbox(inbox.to_string()))? + .to_string(); + if let Some(port) = url.port() { + host = format!("{host}:{port}"); + } + let date = httpdate::fmt_http_date(SystemTime::now()); + let digest = create_sha256_digest_header(&body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host.as_str()), + ]; + let mut request_target = url.path().to_string(); + if let Some(query) = url.query() { + request_target.push('?'); + request_target.push_str(query); + } + let signature = + sign_draft_cavage(key_pair, key_id.as_str(), "POST", &request_target, &headers) + .map_err(SendError::Sign)?; + + let response = self + .client + .post(url) + .header(CONTENT_TYPE, "application/activity+json") + .header(DATE, date) + .header("Digest", digest) + .header(HOST, host) + .header("Signature", signature) + .body(body) + .send() + .await + .map_err(SendError::Request)?; + + if !response.status().is_success() { + return Err(SendError::UnsuccessfulStatus { + inbox: inbox.to_string(), + status: response.status(), + }); + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("failed to build HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("failed to serialize activity")] + Serialize(#[source] serde_json::Error), + + #[error("local actor {0} does not advertise a signing key")] + MissingActorKey(String), + + #[error("stored signing key does not match local actor {0}")] + ActorKeyMismatch(String), + + #[error("invalid recipient inbox: {0}")] + InvalidInbox(String), + + #[error("recipient inbox {inbox} resolves to non-public address {address}")] + PrivateInboxAddress { + inbox: String, + address: std::net::IpAddr, + }, + + #[error("failed to sign activity request")] + Sign(#[source] HttpSignatureError), + + #[error("failed to send activity")] + Request(#[source] reqwest::Error), + + #[error("sending activity to {inbox} returned {status}")] + UnsuccessfulStatus { inbox: String, status: StatusCode }, +} diff --git a/crates/feder-server/src/storage/mod.rs b/crates/feder-server/src/storage/mod.rs new file mode 100644 index 0000000..1f1fc82 --- /dev/null +++ b/crates/feder-server/src/storage/mod.rs @@ -0,0 +1,47 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod sqlite; + +pub use sqlite::SqliteStore; + +use feder_core::key::KeyError; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("I/O error")] + Io(#[from] std::io::Error), + + #[error("SQLite error")] + Sqlite(#[from] rusqlite::Error), + + #[error("JSON error")] + Json(#[from] serde_json::Error), + + #[error("invalid IRI: {0}")] + InvalidIri(String), + + #[error("unsupported stored object type: {0}")] + UnsupportedStoredObjectType(String), + + #[error("storage lock poisoned")] + LockPoisoned, + + #[error("actor key was not stored after provisioning")] + ActorKeyProvisioning, + + #[error(transparent)] + ActorKey(#[from] KeyError), +} diff --git a/crates/feder-server/src/storage/sqlite.rs b/crates/feder-server/src/storage/sqlite.rs new file mode 100644 index 0000000..93ea60e --- /dev/null +++ b/crates/feder-server/src/storage/sqlite.rs @@ -0,0 +1,431 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + path::Path, + sync::{Mutex, MutexGuard}, +}; + +#[cfg(unix)] +use std::{ + fs::OpenOptions, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, +}; + +use feder_core::{ + follow::PendingFollow, + key::{ActorKeyPair, generate_actor_key_pair}, + storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, +}; +use feder_vocab::{Actor, Iri, Note}; +use rand_core::CryptoRngCore; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; + +use super::StoreError; + +pub struct SqliteStore { + connection: Mutex, +} + +impl SqliteStore { + pub fn open(path: &Path) -> Result { + #[cfg(unix)] + let database_file = prepare_database_file(path)?; + + let store = Self { + connection: Mutex::new(Connection::open(path)?), + }; + + #[cfg(unix)] + drop(database_file); + + store.init()?; + Ok(store) + } + + pub fn open_in_memory() -> Result { + let store = Self { + connection: Mutex::new(Connection::open_in_memory()?), + }; + store.init()?; + Ok(store) + } + + pub fn insert_actor_key_pair( + &self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError> { + self.connection()?.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + actor_id.as_str(), + key_pair.private_key_pem(), + key_pair.public_key_pem(), + ], + )?; + Ok(()) + } + + /// Loads the actor's existing signing identity or provisions it once. + /// + /// Concurrent provisioners keep the first key pair inserted for the actor; + /// an existing identity is never replaced. + pub fn load_or_generate_actor_key_pair( + &self, + actor_id: &Iri, + rng: &mut (impl CryptoRngCore + ?Sized), + ) -> Result { + self.load_or_insert_actor_key_pair(actor_id, || { + generate_actor_key_pair(rng).map_err(StoreError::from) + }) + } + + fn load_or_insert_actor_key_pair( + &self, + actor_id: &Iri, + generate: impl FnOnce() -> Result, + ) -> Result { + { + let connection = self.connection()?; + if let Some(key_pair) = load_actor_key_pair(&connection, actor_id)? { + return Ok(key_pair); + } + } + + let generated = generate()?; + let connection = self.connection()?; + let inserted = connection.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + ON CONFLICT(actor_id) DO NOTHING + "#, + params![ + actor_id.as_str(), + generated.private_key_pem(), + generated.public_key_pem(), + ], + )?; + if inserted == 1 { + Ok(generated) + } else { + load_actor_key_pair(&connection, actor_id)?.ok_or(StoreError::ActorKeyProvisioning) + } + } + + fn init(&self) -> Result<(), StoreError> { + self.connection()?.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS followers ( + follower_actor_id TEXT NOT NULL, + following_actor_id TEXT NOT NULL, + inbox_url TEXT, + shared_inbox_url TEXT, + PRIMARY KEY (follower_actor_id, following_actor_id) + ); + CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id + ON followers (following_actor_id); + CREATE TABLE IF NOT EXISTS keys ( + actor_id TEXT PRIMARY KEY NOT NULL, + private_key_pem TEXT NOT NULL, + public_key_pem TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY NOT NULL, + object_type TEXT NOT NULL, + object_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS outbound_follows ( + follow_activity_id TEXT PRIMARY KEY NOT NULL, + local_actor_id TEXT NOT NULL, + remote_actor_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'accepted')) + ); + "#, + )?; + Ok(()) + } + + fn connection(&self) -> Result, StoreError> { + self.connection.lock().map_err(|_| StoreError::LockPoisoned) + } +} + +#[cfg(unix)] +fn prepare_database_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + Ok(file) +} + +impl Storage for SqliteStore { + type Error = StoreError; +} + +impl ServerStorage for SqliteStore { + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { + let shared_inbox = follower + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()); + self.connection()?.execute( + r#" + INSERT INTO followers ( + follower_actor_id, + following_actor_id, + inbox_url, + shared_inbox_url + ) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(follower_actor_id, following_actor_id) DO UPDATE SET + inbox_url = excluded.inbox_url, + shared_inbox_url = excluded.shared_inbox_url + "#, + params![ + follower.id.as_str(), + following.as_str(), + follower.inbox.as_str(), + shared_inbox.map(|inbox| inbox.as_str()), + ], + )?; + Ok(()) + } + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { + let connection = self.connection()?; + load_actor_key_pair(&connection, actor_id) + } + + fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error> { + self.connection()?.execute( + r#" + DELETE FROM followers + WHERE follower_actor_id = ?1 AND following_actor_id = ?2 + "#, + params![follower.as_str(), following.as_str()], + )?; + Ok(()) + } + + fn list_followers(&self, following: &Iri) -> Result, Self::Error> { + let connection = self.connection()?; + let mut statement = connection.prepare( + r#" + SELECT follower_actor_id + FROM followers + WHERE following_actor_id = ?1 + ORDER BY follower_actor_id + "#, + )?; + let rows = statement.query_map([following.as_str()], |row| row.get::<_, String>(0))?; + rows.map(|row| parse_iri(row?)).collect() + } + + fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error> { + let remote_actor_json = serde_json::to_string(&follow.remote_actor)?; + self.connection()?.execute( + r#" + INSERT INTO outbound_follows ( + follow_activity_id, + local_actor_id, + remote_actor_json, + state + ) + VALUES (?1, ?2, ?3, 'pending') + ON CONFLICT(follow_activity_id) DO UPDATE SET + local_actor_id = excluded.local_actor_id, + remote_actor_json = excluded.remote_actor_json, + state = 'pending' + "#, + params![ + follow.follow_activity.as_str(), + follow.local_actor.as_str(), + remote_actor_json, + ], + )?; + Ok(()) + } + + fn load_pending_follow( + &self, + follow_activity: &Iri, + ) -> Result, Self::Error> { + let connection = self.connection()?; + load_pending_follow(&connection, follow_activity) + } + + fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let stored = load_pending_follow(&transaction, &expected.follow_activity)?; + if stored.as_ref() != Some(expected) { + return Ok(false); + } + let changed = transaction.execute( + r#" + UPDATE outbound_follows + SET state = 'accepted' + WHERE follow_activity_id = ?1 AND state = 'pending' + "#, + [expected.follow_activity.as_str()], + )?; + transaction.commit()?; + Ok(changed == 1) + } +} + +impl NoteStore for SqliteStore { + fn store_note(&self, note: &Note) -> Result<(), Self::Error> { + let note_json = serde_json::to_string(note)?; + self.connection()?.execute( + r#" + INSERT INTO objects (object_id, object_type, object_json) + VALUES (?1, 'Note', ?2) + ON CONFLICT(object_id) DO UPDATE SET + object_type = excluded.object_type, + object_json = excluded.object_json + "#, + params![note.id.as_str(), note_json], + )?; + Ok(()) + } + + fn load_note(&self, note_id: &Iri) -> Result, Self::Error> { + let stored = self + .connection()? + .query_row( + r#" + SELECT object_type, object_json + FROM objects + WHERE object_id = ?1 + "#, + [note_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + stored + .map(|(object_type, object_json)| { + if object_type != "Note" { + return Err(StoreError::UnsupportedStoredObjectType(object_type)); + } + serde_json::from_str(&object_json).map_err(StoreError::from) + }) + .transpose() + } +} + +impl FollowerDeliveryStore for SqliteStore { + fn list_follower_delivery_targets( + &self, + local_actor: &Iri, + ) -> Result, Self::Error> { + let connection = self.connection()?; + let mut statement = connection.prepare( + r#" + SELECT follower_actor_id, inbox_url, shared_inbox_url + FROM followers + WHERE following_actor_id = ?1 AND inbox_url IS NOT NULL + ORDER BY follower_actor_id + "#, + )?; + let rows = statement.query_map([local_actor.as_str()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + + rows.map(|row| { + let (actor_id, inbox, shared_inbox) = row?; + Ok(FollowerDeliveryTarget { + actor_id: parse_iri(actor_id)?, + inbox: parse_iri(inbox)?, + shared_inbox: shared_inbox.map(parse_iri).transpose()?, + }) + }) + .collect() + } +} + +fn load_pending_follow( + connection: &Connection, + follow_activity: &Iri, +) -> Result, StoreError> { + let stored = connection + .query_row( + r#" + SELECT local_actor_id, remote_actor_json + FROM outbound_follows + WHERE follow_activity_id = ?1 AND state = 'pending' + "#, + [follow_activity.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + stored + .map(|(local_actor, remote_actor_json)| { + Ok(PendingFollow { + local_actor: parse_iri(local_actor)?, + remote_actor: serde_json::from_str(&remote_actor_json)?, + follow_activity: follow_activity.clone(), + }) + }) + .transpose() +} + +fn load_actor_key_pair( + connection: &Connection, + actor_id: &Iri, +) -> Result, StoreError> { + let encoded_keys = connection + .query_row( + r#" + SELECT private_key_pem, public_key_pem + FROM keys + WHERE actor_id = ?1 + "#, + [actor_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + encoded_keys + .map(|(private_key_pem, public_key_pem)| { + ActorKeyPair::from_pem(private_key_pem, public_key_pem) + }) + .transpose() + .map_err(StoreError::from) +} + +fn parse_iri(value: String) -> Result { + value + .parse() + .map_err(|_| StoreError::InvalidIri(value.to_owned())) +} diff --git a/crates/feder-server/src/url.rs b/crates/feder-server/src/url.rs new file mode 100644 index 0000000..012d7be --- /dev/null +++ b/crates/feder-server/src/url.rs @@ -0,0 +1,149 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::LazyLock, + time::Duration, +}; + +use ipnet::IpNet; +use reqwest::{ + Client, Url, + dns::{Addrs, Name, Resolve, Resolving}, + redirect::Policy, +}; +use url::Host; + +use crate::config::OutboundAddressPolicy; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fec0::/10", + "fe80::/10", + "ff00::/8", +]; + +static NON_PUBLIC_NETWORKS: LazyLock> = LazyLock::new(|| { + NON_PUBLIC_NETWORK_CIDRS + .iter() + .map(|cidr| cidr.parse().expect("hardcoded network CIDR is valid")) + .collect() +}); + +pub(crate) fn build_client(policy: OutboundAddressPolicy) -> Result { + Client::builder() + .dns_resolver(PublicDnsResolver { policy }) + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() +} + +pub(crate) fn validate_literal_host( + url: &Url, + policy: OutboundAddressPolicy, +) -> Result<(), IpAddr> { + if policy == OutboundAddressPolicy::AllowPrivateAddress { + return Ok(()); + } + + match url.host() { + Some(Host::Ipv4(address)) => validate_public_address(address.into()), + Some(Host::Ipv6(address)) => validate_public_address(address.into()), + Some(Host::Domain(_)) | None => Ok(()), + } +} + +#[derive(Clone, Copy, Debug)] +struct PublicDnsResolver { + policy: OutboundAddressPolicy, +} + +impl Resolve for PublicDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + let policy = self.policy; + + Box::pin(async move { + let addresses = tokio::net::lookup_host((host.as_str(), 0)) + .await? + .collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{host} resolved to no addresses"), + ) + .into()); + } + if policy == OutboundAddressPolicy::PublicOnly { + for address in &addresses { + validate_public_address(address.ip()).map_err(|blocked| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{host} resolved to non-public address {blocked}"), + ) + })?; + } + } + + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +fn validate_public_address(address: IpAddr) -> Result<(), IpAddr> { + if let IpAddr::V6(address) = address + && let Some(mapped) = address.to_ipv4_mapped() + { + return validate_public_address(mapped.into()); + } + let is_public = !NON_PUBLIC_NETWORKS + .iter() + .any(|network| network.contains(&address)); + + if is_public { Ok(()) } else { Err(address) } +} diff --git a/crates/feder-server/src/webfinger.rs b/crates/feder-server/src/webfinger.rs new file mode 100644 index 0000000..e4c3040 --- /dev/null +++ b/crates/feder-server/src/webfinger.rs @@ -0,0 +1,91 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use crate::FederServer; +use axum::{ + Json, + extract::{Query, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::ActorDispatcher; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct WebFingerQuery { + resource: Option, +} + +#[derive(Serialize)] +pub struct WebFingerLink { + rel: &'static str, + #[serde(rename = "type")] + media_type: &'static str, + href: String, +} + +#[derive(Serialize)] +pub struct WebFingerResponse { + subject: String, + aliases: Vec, + links: Vec, +} + +pub async fn webfinger( + State(server): State>>, + Query(query): Query, +) -> Result +where + A: ActorDispatcher, +{ + let resource = query.resource.ok_or(StatusCode::BAD_REQUEST)?; + + let account = resource + .strip_prefix("acct:") + .ok_or(StatusCode::BAD_REQUEST)?; + + let (identifier, resource_host) = account.rsplit_once('@').ok_or(StatusCode::BAD_REQUEST)?; + + if identifier.is_empty() || resource_host.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + if !resource_host.eq_ignore_ascii_case(server.handle_host()) { + return Err(StatusCode::NOT_FOUND); + } + + let actor = server + .get_actor(identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let actor_id = actor.id.to_string(); + + Ok(( + [(header::CONTENT_TYPE, "application/jrd+json")], + Json(WebFingerResponse { + subject: resource, + aliases: vec![actor_id.clone()], + links: vec![WebFingerLink { + rel: "self", + media_type: "application/activity+json", + href: actor_id, + }], + }), + ) + .into_response()) +} diff --git a/crates/feder-server/tests/cases/actor.rs b/crates/feder-server/tests/cases/actor.rs new file mode 100644 index 0000000..a8c942b --- /dev/null +++ b/crates/feder-server/tests/cases/actor.rs @@ -0,0 +1,161 @@ +use axum::{ + Json, Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + routing::get, +}; +use feder_server::{ActorResolveError, ActorResolver, OutboundAddressPolicy}; +use feder_vocab::Actor; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router}; + +#[tokio::test] +async fn returns_local_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/users/alice") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let body = to_bytes(response.into_body(), 8192) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["preferredUsername"], "alice"); + assert_eq!( + json["publicKey"]["id"], + "http://127.0.0.1:3000/users/alice#main-key" + ); + assert_eq!( + json["publicKey"]["publicKeyPem"], + include_str!("../fixtures/rsa-public-key.pem") + ); +} + +#[tokio::test] +async fn rejects_actor_request_without_acceptable_media_type() { + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let mut request = Request::builder().uri("/users/alice"); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + let response = test_router() + .oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} + +#[tokio::test] +async fn rejects_unknown_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/users/bob") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +async fn spawn_actor_server( + content_type: &'static str, + mismatched_id: bool, +) -> (feder_vocab::Iri, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let returned_id = if mismatched_id { + iri(&format!("http://{address}/users/mallory")) + } else { + actor_id.clone() + }; + let actor = Actor::person( + returned_id, + iri(&format!("http://{address}/users/bob/inbox")), + iri(&format!("http://{address}/users/bob/outbox")), + ); + let app = Router::new().route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(header::CONTENT_TYPE, content_type)], Json(actor)) } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve actor"); + }); + (actor_id, task) +} + +#[tokio::test] +async fn resolves_remote_actor_and_checks_canonical_id() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct resolver"); + let (actor_id, server) = spawn_actor_server("application/activity+json", false).await; + + let actor = resolver.resolve(&actor_id).await.expect("resolve actor"); + + assert_eq!(actor.id, actor_id); + server.abort(); +} + +#[tokio::test] +async fn rejects_mismatched_actor_id_and_unsupported_content_type() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct resolver"); + let (mismatched_id, mismatched_server) = + spawn_actor_server("application/activity+json", true).await; + let mismatch = resolver.resolve(&mismatched_id).await; + assert!(matches!( + mismatch, + Err(ActorResolveError::ActorIdMismatch { .. }) + )); + mismatched_server.abort(); + + let (html_id, html_server) = spawn_actor_server("text/html", false).await; + let unsupported = resolver.resolve(&html_id).await; + assert!(matches!( + unsupported, + Err(ActorResolveError::UnsupportedContentType(_)) + )); + html_server.abort(); +} + +#[tokio::test] +async fn public_policy_blocks_loopback_actor_resolution() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::PublicOnly).expect("construct resolver"); + let actor_id = iri("http://127.0.0.1:3000/users/bob"); + + let result = resolver.resolve(&actor_id).await; + + assert!(matches!( + result, + Err(ActorResolveError::PrivateResourceAddress { address, .. }) if address.is_loopback() + )); +} diff --git a/crates/feder-server/tests/cases/followers.rs b/crates/feder-server/tests/cases/followers.rs new file mode 100644 index 0000000..c1b2deb --- /dev/null +++ b/crates/feder-server/tests/cases/followers.rs @@ -0,0 +1,115 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::storage::ServerStorage; +use feder_vocab::Actor; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router, test_router_with_storage}; + +async fn get_followers( + app: Router, + identifier: &str, + accept: Option<&str>, +) -> axum::response::Response { + let mut request = Request::builder().uri(format!("/users/{identifier}/followers")); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +async fn response_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + serde_json::from_slice(&body).expect("valid JSON") +} + +fn remote_actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} + +#[tokio::test] +async fn returns_empty_followers_collection_with_activitypub_headers() { + let response = get_followers(test_router(), "alice", Some("application/activity+json")).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + let json = response_json(response).await; + assert_eq!(json["type"], "OrderedCollection"); + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn returns_stored_followers_in_stable_order() { + let app = test_router_with_storage(|storage| { + let following = iri("http://127.0.0.1:3000/users/alice"); + storage + .store_follower( + &remote_actor("https://remote.example/users/carol"), + &following, + ) + .expect("store Carol"); + storage + .store_follower( + &remote_actor("https://remote.example/users/bob"), + &following, + ) + .expect("store Bob"); + }); + + let response = get_followers(app, "alice", Some("application/activity+json")).await; + let json = response_json(response).await; + assert_eq!(json["totalItems"], 2); + assert_eq!( + json["orderedItems"], + serde_json::json!([ + "https://remote.example/users/bob", + "https://remote.example/users/carol" + ]) + ); +} + +#[tokio::test] +async fn reflects_follower_removal() { + let app = test_router_with_storage(|storage| { + let following = iri("http://127.0.0.1:3000/users/alice"); + let follower = remote_actor("https://remote.example/users/bob"); + storage + .store_follower(&follower, &following) + .expect("store follower"); + storage + .remove_follower(&follower.id, &following) + .expect("remove follower"); + }); + + let response = get_followers(app, "alice", Some("application/activity+json")).await; + assert_eq!(response_json(response).await["totalItems"], 0); +} + +#[tokio::test] +async fn rejects_unknown_actor_and_unacceptable_media_types() { + let unknown = get_followers(test_router(), "bob", Some("application/activity+json")).await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let response = get_followers(test_router(), "alice", accept).await; + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} diff --git a/crates/feder-server/tests/cases/inbox.rs b/crates/feder-server/tests/cases/inbox.rs new file mode 100644 index 0000000..8602053 --- /dev/null +++ b/crates/feder-server/tests/cases/inbox.rs @@ -0,0 +1,347 @@ +use axum::{ + Json, Router, + body::{Body, Bytes, to_bytes}, + http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, + routing::{get, post}, +}; +use feder_core::key::{create_sha256_digest_header, sign_draft_cavage}; +use feder_server::InboxAuthPolicy; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::common::{ + HANDLE_HOST, ORIGIN, RecordedRequest, actor_key_pair, test_router, test_router_with_policy, +}; + +fn follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": format!("{ORIGIN}/users/alice") + })) + .expect("serialize Follow") +} + +fn undo_follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": format!("{actor_id}/undos/1"), + "actor": actor_id, + "object": { + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": format!("{ORIGIN}/users/alice") + } + })) + .expect("serialize Undo") +} + +async fn spawn_remote_actor() -> ( + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind remote actor server"); + let address = listener.local_addr().expect("remote actor server address"); + let actor_id = format!("http://{address}/users/bob"); + let key_id = format!("{actor_id}#main-key"); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox"), + "preferredUsername": "bob", + "publicKey": { + "id": key_id, + "owner": actor_id, + "publicKeyPem": actor_key_pair().public_key_pem() + } + }); + let (sender, receiver) = tokio::sync::mpsc::channel(2); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + StatusCode::ACCEPTED + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve remote actor"); + }); + + (actor_id, receiver, task) +} + +async fn post_inbox( + app: Router, + uri: &str, + content_type: &str, + body: impl Into, +) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, content_type) + .body(body.into()) + .expect("valid inbox request"), + ) + .await + .expect("inbox response") +} + +async fn post_signed_inbox( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, +) -> axum::response::Response { + let date = httpdate::fmt_http_date(std::time::SystemTime::now()); + let digest = create_sha256_digest_header(signed_body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host), + ]; + let signature = sign_draft_cavage( + &actor_key_pair(), + &format!("{actor_id}#main-key"), + "POST", + uri, + &headers, + ) + .expect("sign inbox request"); + + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, "application/activity+json") + .header("date", date) + .header("digest", digest) + .header("host", host) + .header("signature", signature) + .body(delivered_body.into()) + .expect("valid signed inbox request"), + ) + .await + .expect("signed inbox response") +} + +async fn follower_count(app: Router) -> u64 { + let response = app + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .header("accept", "application/activity+json") + .body(Body::empty()) + .expect("valid followers request"), + ) + .await + .expect("followers response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read followers response"); + let collection: Value = serde_json::from_slice(&body).expect("valid followers collection"); + collection["totalItems"] + .as_u64() + .expect("numeric follower count") +} + +#[tokio::test] +async fn valid_signed_follow_is_stored_and_accept_is_sent() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 1); + let request = requests.recv().await.expect("receive Accept activity"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Accept activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["actor"], format!("{ORIGIN}/users/alice")); + remote_server.abort(); +} + +#[tokio::test] +async fn unsigned_follow_is_rejected_when_signatures_are_required() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let body = follow_body(&actor_id); + + let response = post_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_wrong_host() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + "other.example", + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_a_tampered_body() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let signed_body = follow_body(&actor_id); + let delivered_body = follow_body("https://attacker.example/users/mallory"); + + let response = post_signed_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + &actor_id, + &signed_body, + delivered_body, + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_undo_removes_the_persisted_follower() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let follow = follow_body(&actor_id); + let follow_response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &follow, + follow.clone(), + HANDLE_HOST, + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + + let undo = undo_follow_body(&actor_id); + let undo_response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &undo, + undo.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 0); + remote_server.abort(); +} + +#[tokio::test] +async fn shared_inbox_routes_a_signed_follow() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + app.clone(), + "/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 1); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn inbox_rejects_invalid_content_before_dispatch() { + let unsupported = post_inbox( + test_router(), + "/users/alice/inbox", + "application/json", + "{}", + ) + .await; + assert_eq!(unsupported.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let malformed = post_inbox( + test_router(), + "/users/alice/inbox", + "application/activity+json", + "{not json", + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn personal_inbox_rejects_an_unknown_local_actor() { + let response = post_inbox( + test_router(), + "/users/bob/inbox", + "application/activity+json", + "{}", + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-server/tests/cases/object.rs b/crates/feder-server/tests/cases/object.rs new file mode 100644 index 0000000..f61930d --- /dev/null +++ b/crates/feder-server/tests/cases/object.rs @@ -0,0 +1,129 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::{note::PUBLIC_COLLECTION, storage::NoteStore}; +use feder_vocab::{Note, Reference, References}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router_with_storage}; + +fn stored_note() -> Note { + let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); + note.to = References::one(iri(PUBLIC_COLLECTION)); + note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); + note +} + +fn router_with_note(note: Note) -> Router { + test_router_with_storage(|storage| storage.store_note(¬e).expect("store Note")) +} + +async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +#[tokio::test] +async fn returns_public_stored_note_with_activitypub_headers() { + let response = get_object( + router_with_note(stored_note()), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["type"], "Note"); + assert_eq!(json["content"], "Hello from Feder."); +} + +#[tokio::test] +async fn returns_note_when_public_is_in_cc() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::one(iri(PUBLIC_COLLECTION)); + + let response = get_object( + router_with_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn hides_non_public_notes() { + for (to, cc) in [ + ( + References::one(iri("https://remote.example/users/bob")), + References::new(), + ), + ( + References::one(iri("http://127.0.0.1:3000/users/alice/followers")), + References::new(), + ), + (References::new(), References::new()), + ] { + let mut note = stored_note(); + note.to = to; + note.cc = cc; + let response = get_object( + router_with_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} + +#[tokio::test] +async fn rejects_unknown_routes_and_unacceptable_media_types() { + let unknown_note = get_object( + router_with_note(stored_note()), + "/users/alice/posts/unknown", + Some("application/activity+json"), + ) + .await; + assert_eq!(unknown_note.status(), StatusCode::NOT_FOUND); + + let unknown_actor = get_object( + router_with_note(stored_note()), + "/users/bob/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(unknown_actor.status(), StatusCode::NOT_FOUND); + + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let response = get_object( + router_with_note(stored_note()), + "/users/alice/posts/1", + accept, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} diff --git a/crates/feder-server/tests/cases/operation.rs b/crates/feder-server/tests/cases/operation.rs new file mode 100644 index 0000000..e9a427f --- /dev/null +++ b/crates/feder-server/tests/cases/operation.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use axum::{Json, Router, body::Body, http::header::CONTENT_TYPE, routing::get}; +use feder_core::{ + note::{CreateNoteInput, PUBLIC_COLLECTION}, + storage::ServerStorage, +}; +use feder_server::{InboxAuthPolicy, build_router_with_state, storage::SqliteStore}; +use feder_vocab::{Actor, Iri, References}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::common::{ORIGIN, RecordedRequest, iri, spawn_inbox_server, test_server_with_storage}; + +fn create_note_input() -> CreateNoteInput { + CreateNoteInput { + note_id: iri(&format!("{ORIGIN}/users/alice/posts/1")), + create_id: iri(&format!("{ORIGIN}/users/alice/activities/create/1")), + to: References::one(iri(PUBLIC_COLLECTION)), + cc: References::one(iri(&format!("{ORIGIN}/users/alice/followers"))), + content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), + published: Some("2026-07-21T00:00:00Z".to_string()), + url: Some(iri(&format!("{ORIGIN}/@alice/1"))), + } +} + +async fn spawn_remote_actor() -> ( + Iri, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind remote actor server"); + let address = listener.local_addr().expect("remote actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox") + }); + let (sender, receiver) = tokio::sync::mpsc::channel(2); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + axum::routing::post( + move |headers: axum::http::HeaderMap, + uri: axum::http::Uri, + body: axum::body::Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + axum::http::StatusCode::ACCEPTED + } + }, + ), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve remote actor"); + }); + + (actor_id, receiver, task) +} + +#[tokio::test] +async fn outbound_follow_is_persisted_before_signed_delivery() { + let (remote_actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let server = test_server_with_storage(|_| {}, InboxAuthPolicy::RequireSigned); + let local_actor_id = iri(&format!("{ORIGIN}/users/alice")); + let follow_id = iri(&format!("{ORIGIN}/users/alice/activities/follow/1")); + + let follow = server + .follow_actor(&local_actor_id, &remote_actor_id, follow_id.clone()) + .await + .expect("follow remote actor"); + + assert_eq!(follow.id, follow_id); + let request = requests.recv().await.expect("receive Follow delivery"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Follow activity"); + assert_eq!(activity["type"], "Follow"); + assert_eq!(activity["actor"], local_actor_id.as_str()); + assert_eq!(activity["object"], remote_actor_id.as_str()); + remote_server.abort(); +} + +#[tokio::test] +async fn create_note_persists_and_delivers_to_followers() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(axum::http::StatusCode::ACCEPTED).await; + let remote_actor_id = iri("https://remote.example/users/bob"); + let server = test_server_with_storage( + |storage: &SqliteStore| { + let remote_actor = Actor::person( + remote_actor_id.clone(), + iri(&inbox), + iri("https://remote.example/users/bob/outbox"), + ); + storage + .store_follower(&remote_actor, &iri(&format!("{ORIGIN}/users/alice"))) + .expect("store follower"); + }, + InboxAuthPolicy::RequireSigned, + ); + let server = Arc::new(server); + let local_actor_id = iri(&format!("{ORIGIN}/users/alice")); + + let outcome = server + .create_note(&local_actor_id, create_note_input()) + .await + .expect("create Note"); + + assert_eq!(outcome.note.content.as_deref(), Some("Hello from Feder.")); + let request = requests.recv().await.expect("receive Create delivery"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Create activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["object"]["id"], outcome.note.id.as_str()); + + let response = build_router_with_state(server) + .oneshot( + axum::http::Request::builder() + .uri("/users/alice/posts/1") + .header("accept", "application/activity+json") + .body(Body::empty()) + .expect("valid object request"), + ) + .await + .expect("object response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + inbox_server.abort(); +} diff --git a/crates/feder-server/tests/cases/send.rs b/crates/feder-server/tests/cases/send.rs new file mode 100644 index 0000000..fc8c1f8 --- /dev/null +++ b/crates/feder-server/tests/cases/send.rs @@ -0,0 +1,113 @@ +use axum::http::StatusCode; +use feder_core::key::verify_draft_cavage; +use feder_server::{ + OutboundAddressPolicy, + send::{ActivitySender, SendError}, +}; +use feder_vocab::{Follow, Reference}; + +use crate::common::{actor_key_pair, iri, local_actor, spawn_inbox_server}; + +fn follow() -> Follow { + Follow::new( + iri("https://local.example/activities/follow/1"), + Reference::id(iri("http://127.0.0.1:3000/users/alice")), + Reference::id(iri("https://remote.example/users/bob")), + ) +} + +#[tokio::test] +async fn sends_signed_activity_to_exact_inbox_target() { + let (inbox, mut requests, server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = format!("{inbox}?shared=true"); + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + let actor = local_actor(); + let key_pair = actor_key_pair(); + + sender + .send_activity(&actor, &key_pair, &follow(), &iri(&inbox)) + .await + .expect("send Follow"); + + let request = requests.recv().await.expect("receive request"); + assert_eq!(request.uri, "/inbox?shared=true"); + assert_eq!(request.headers["content-type"], "application/activity+json"); + assert_eq!( + request.headers["digest"], + feder_core::key::create_sha256_digest_header(&request.body) + ); + let signature = request.headers["signature"] + .to_str() + .expect("signature header") + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + let headers = [ + ( + "content-type", + request.headers["content-type"].to_str().unwrap(), + ), + ("date", request.headers["date"].to_str().unwrap()), + ("digest", request.headers["digest"].to_str().unwrap()), + ("host", request.headers["host"].to_str().unwrap()), + ]; + verify_draft_cavage( + key_pair.public_key_pem(), + "POST", + "/inbox?shared=true", + &headers, + signature, + ) + .expect("verify sent request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid activity"); + assert_eq!(activity["type"], "Follow"); + server.abort(); +} + +#[tokio::test] +async fn reports_unsuccessful_inbox_status() { + let (inbox, mut requests, server) = spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + + let result = sender + .send_activity(&local_actor(), &actor_key_pair(), &follow(), &iri(&inbox)) + .await; + + assert!(matches!(result, Err(SendError::UnsuccessfulStatus { .. }))); + requests.recv().await.expect("receive failed request"); + server.abort(); +} + +#[tokio::test] +async fn public_policy_blocks_loopback_inbox() { + let sender = ActivitySender::new(OutboundAddressPolicy::PublicOnly).expect("construct sender"); + let inbox = iri("http://127.0.0.1:3000/inbox"); + + let result = sender + .send_activity(&local_actor(), &actor_key_pair(), &follow(), &inbox) + .await; + + assert!(matches!( + result, + Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() + )); +} + +#[tokio::test] +async fn rejects_missing_or_mismatched_actor_key() { + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + let key_pair = actor_key_pair(); + let mut actor = local_actor(); + actor.public_key = None; + let inbox = iri("https://remote.example/inbox"); + + let missing = sender + .send_activity(&actor, &key_pair, &follow(), &inbox) + .await; + + assert!(matches!(missing, Err(SendError::MissingActorKey(_)))); +} diff --git a/crates/feder-server/tests/cases/webfinger.rs b/crates/feder-server/tests/cases/webfinger.rs new file mode 100644 index 0000000..e13002d --- /dev/null +++ b/crates/feder-server/tests/cases/webfinger.rs @@ -0,0 +1,62 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::test_router; + +#[tokio::test] +async fn returns_webfinger_descriptor_for_local_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/jrd+json" + ); + let body = to_bytes(response.into_body(), 1024) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); + assert_eq!( + json["links"][0]["href"], + "http://127.0.0.1:3000/users/alice" + ); +} + +#[tokio::test] +async fn rejects_missing_malformed_unknown_and_non_authoritative_resources() { + for uri in [ + "/.well-known/webfinger", + "/.well-known/webfinger?resource=https://127.0.0.1/users/alice", + "/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000", + "/.well-known/webfinger?resource=acct:alice@attacker.example", + ] { + let response = test_router() + .oneshot( + Request::builder() + .uri(uri) + .header(header::HOST, "attacker.example") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert!(matches!( + response.status(), + StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND + )); + } +} diff --git a/crates/feder-server/tests/common/mod.rs b/crates/feder-server/tests/common/mod.rs new file mode 100644 index 0000000..6d1928d --- /dev/null +++ b/crates/feder-server/tests/common/mod.rs @@ -0,0 +1,143 @@ +use std::convert::Infallible; + +use axum::{ + Router, + body::Bytes, + http::{HeaderMap, StatusCode, Uri}, + routing::post, +}; +use feder_core::{ActorDispatcher, key::ActorKeyPair}; +use feder_server::{ + FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, +}; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use tokio::{sync::mpsc, task::JoinHandle}; + +pub const IDENTIFIER: &str = "alice"; +pub const ORIGIN: &str = "http://127.0.0.1:3000"; +pub const HANDLE_HOST: &str = "127.0.0.1:3000"; + +const PRIVATE_KEY_PEM: &str = include_str!("../fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("../fixtures/rsa-public-key.pem"); + +pub struct TestActors { + actor: Actor, +} + +pub struct RecordedRequest { + pub headers: HeaderMap, + pub uri: Uri, + pub body: Bytes, +} + +impl ActorDispatcher for TestActors { + type Error = Infallible; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) + } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) + } +} + +pub fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +pub fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair fixture") +} + +pub fn local_actor() -> Actor { + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); + let key_pair = actor_key_pair(); + let mut actor = Actor::person( + iri(&actor_id), + iri(&format!("{actor_id}/inbox")), + iri(&format!("{actor_id}/outbox")), + ); + actor.preferred_username = Some(IDENTIFIER.to_string()); + actor.name = Some("Alice".to_string()); + actor.followers = Some(iri(&format!("{actor_id}/followers"))); + actor.endpoints = Some(Endpoints { + shared_inbox: Some(iri(&format!("{ORIGIN}/inbox"))), + }); + actor.set_public_key(Reference::object(CryptographicKey::new( + iri(&format!("{actor_id}#main-key")), + actor.id.clone(), + key_pair.public_key_pem().to_string(), + ))); + actor +} + +pub fn test_router() -> Router { + test_router_with_storage(|_| {}) +} + +pub fn test_router_with_storage(configure: impl FnOnce(&SqliteStore)) -> Router { + test_router_with_storage_and_policy(configure, InboxAuthPolicy::AllowUnsignedInsecureDev) +} + +pub fn test_router_with_policy(inbox_auth_policy: InboxAuthPolicy) -> Router { + test_router_with_storage_and_policy(|_| {}, inbox_auth_policy) +} + +fn test_router_with_storage_and_policy( + configure: impl FnOnce(&SqliteStore), + inbox_auth_policy: InboxAuthPolicy, +) -> Router { + build_router(test_server_with_storage(configure, inbox_auth_policy)) +} + +pub fn test_server_with_storage( + configure: impl FnOnce(&SqliteStore), + inbox_auth_policy: InboxAuthPolicy, +) -> FederServer { + let actor = local_actor(); + let storage = SqliteStore::open_in_memory().expect("open in-memory store"); + storage + .insert_actor_key_pair(&actor.id, &actor_key_pair()) + .expect("store actor key pair"); + configure(&storage); + FederServer::with_outbound_address_policy( + TestActors { actor }, + storage, + HANDLE_HOST, + OutboundAddressPolicy::AllowPrivateAddress, + ) + .expect("construct Feder server") + .with_inbox_auth_policy(inbox_auth_policy) +} + +pub async fn spawn_inbox_server( + response_status: StatusCode, +) -> (String, mpsc::Receiver, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(2); + let app = Router::new().route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + response_status + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind inbox server"); + let address = listener.local_addr().expect("inbox server address"); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve inbox endpoint"); + }); + + (format!("http://{address}/inbox"), receiver, task) +} diff --git a/crates/feder-server/tests/fixtures/rsa-private-key.pem b/crates/feder-server/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-server/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-server/tests/fixtures/rsa-public-key.pem b/crates/feder-server/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-server/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-server/tests/runtime.rs b/crates/feder-server/tests/runtime.rs new file mode 100644 index 0000000..b7ba75c --- /dev/null +++ b/crates/feder-server/tests/runtime.rs @@ -0,0 +1,16 @@ +mod common; + +#[path = "cases/actor.rs"] +mod actor; +#[path = "cases/followers.rs"] +mod followers; +#[path = "cases/inbox.rs"] +mod inbox; +#[path = "cases/object.rs"] +mod object; +#[path = "cases/operation.rs"] +mod operation; +#[path = "cases/send.rs"] +mod send; +#[path = "cases/webfinger.rs"] +mod webfinger; diff --git a/crates/feder-server/tests/storage.rs b/crates/feder-server/tests/storage.rs new file mode 100644 index 0000000..936b71a --- /dev/null +++ b/crates/feder-server/tests/storage.rs @@ -0,0 +1,160 @@ +use feder_core::{ + follow::PendingFollow, + key::ActorKeyPair, + storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage}, +}; +use feder_server::storage::SqliteStore; +use feder_vocab::{Actor, Endpoints, Iri, Note, Reference}; +use rand_core::OsRng; + +const PRIVATE_KEY_PEM: &str = include_str!("fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-public-key.pem"); + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} + +fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair") +} + +#[test] +fn stores_lists_and_removes_follower_delivery_facts() { + let store = SqliteStore::open_in_memory().expect("open store"); + let following = iri("https://local.example/users/alice"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + + store + .store_follower(&follower, &following) + .expect("store follower"); + + assert_eq!( + store.list_followers(&following).expect("list followers"), + vec![follower.id.clone()] + ); + assert_eq!( + store + .list_follower_delivery_targets(&following) + .expect("list delivery targets"), + vec![FollowerDeliveryTarget { + actor_id: follower.id.clone(), + inbox: follower.inbox.clone(), + shared_inbox: follower + .endpoints + .and_then(|endpoints| endpoints.shared_inbox), + }] + ); + + store + .remove_follower(&follower.id, &following) + .expect("remove follower"); + assert!( + store + .list_followers(&following) + .expect("list followers") + .is_empty() + ); +} + +#[test] +fn stores_and_loads_note() { + let store = SqliteStore::open_in_memory().expect("open store"); + let mut note = Note::new(iri("https://local.example/posts/1")); + note.attributed_to = Some(Reference::id(iri("https://local.example/users/alice"))); + note.content = Some("hello".to_string()); + + store.store_note(¬e).expect("store Note"); + + assert_eq!(store.load_note(¬e.id).expect("load Note"), Some(note)); +} + +#[test] +fn confirms_only_the_expected_pending_follow() { + let store = SqliteStore::open_in_memory().expect("open store"); + let pending = PendingFollow { + local_actor: iri("https://local.example/users/alice"), + remote_actor: actor("https://remote.example/users/bob"), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + store + .store_pending_follow(&pending) + .expect("store pending Follow"); + + let mut wrong = pending.clone(); + wrong.local_actor = iri("https://local.example/users/mallory"); + assert!( + !store + .confirm_pending_follow(&wrong) + .expect("reject mismatch") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load pending Follow"), + Some(pending.clone()) + ); + + assert!( + store + .confirm_pending_follow(&pending) + .expect("confirm pending Follow") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load accepted Follow"), + None + ); +} + +#[test] +fn actor_key_pair_roundtrips() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("store actor keys"); + + assert_eq!( + store + .load_actor_key_pair(&actor_id) + .expect("load actor keys"), + Some(key_pair) + ); +} + +#[test] +fn provisioning_reuses_existing_actor_key_without_generation() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = actor_key_pair(); + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("store actor keys"); + + let provisioned = store + .load_or_generate_actor_key_pair(&actor_id, &mut OsRng) + .expect("reuse actor key"); + + assert_eq!(provisioned, key_pair); +} + +#[test] +fn sqlite_store_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 1c1bc16..a22cb34 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -29,6 +29,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq} /// The canonical Activity Streams JSON-LD context URL. pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; +/// The JSON-LD context for the security vocabulary used by actor public keys. +pub const SECURITY_CONTEXT: &str = "https://w3id.org/security/v1"; + /// An absolute ActivityPub/ActivityStreams identifier. pub type Iri = IriString; @@ -170,7 +173,40 @@ macro_rules! activitystreams_type { activitystreams_type!(NoteType, Note); activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); +activitystreams_type!(UndoType, Undo); activitystreams_type!(CreateType, Create); +activitystreams_type!(OrderedCollectionType, OrderedCollection); + +/// A JSON-LD context represented by one or more IRIs. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Context { + Iri(Iri), + Iris(Vec), +} + +impl Context { + #[must_use] + pub fn one(context: Iri) -> Self { + Self::Iri(context) + } + + #[must_use] + pub fn many(contexts: impl Into>) -> Self { + Self::Iris(contexts.into()) + } + + fn include(&mut self, context: Iri) { + match self { + Self::Iri(existing) if existing == &context => {} + Self::Iri(existing) => { + *self = Self::Iris(Vec::from([existing.clone(), context])); + } + Self::Iris(existing) if !existing.contains(&context) => existing.push(context), + Self::Iris(_) => {} + } + } +} #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub enum ActorType { @@ -189,22 +225,55 @@ pub struct Endpoints { pub shared_inbox: Option, } +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum CryptographicKeyType { + #[default] + CryptographicKey, +} + +/// A public key published by an ActivityPub actor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CryptographicKey { + pub id: Iri, + #[serde(rename = "type", default)] + pub kind: CryptographicKeyType, + pub owner: Iri, + #[serde(rename = "publicKeyPem")] + pub public_key_pem: String, +} + +impl CryptographicKey { + #[must_use] + pub fn new(id: Iri, owner: Iri, public_key_pem: String) -> Self { + Self { + id, + kind: CryptographicKeyType::default(), + owner, + public_key_pem, + } + } +} + /// A minimal ActivityPub actor. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Actor { #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] - pub context: Option, + pub context: Option, #[serde(rename = "type")] pub kind: ActorType, pub id: Iri, pub inbox: Iri, pub outbox: Iri, + #[serde(skip_serializing_if = "Option::is_none")] + pub followers: Option, #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] pub preferred_username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub endpoints: Option, + #[serde(rename = "publicKey", skip_serializing_if = "Option::is_none")] + pub public_key: Option>, } impl Actor { @@ -216,20 +285,38 @@ impl Actor { #[must_use] pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { Self { - context: Some( + context: Some(Context::one( ACTIVITYSTREAMS_CONTEXT .parse() .expect("valid ActivityStreams IRI"), - ), + )), kind, id, inbox, outbox, + followers: None, preferred_username: None, name: None, endpoints: None, + public_key: None, } } + + pub fn set_public_key(&mut self, public_key: Reference) { + let security_context = SECURITY_CONTEXT + .parse() + .expect("valid security context IRI"); + self.context + .get_or_insert_with(|| { + Context::one( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ) + }) + .include(security_context); + self.public_key = Some(public_key); + } } /// A minimal ActivityStreams Note object. @@ -242,10 +329,18 @@ pub struct Note { pub id: Iri, #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] pub attributed_to: Option>, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")] + pub media_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub published: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, } impl Note { @@ -260,8 +355,12 @@ impl Note { kind: NoteType::default(), id, attributed_to: None, + to: References::new(), + cc: References::new(), content: None, + media_type: None, published: None, + url: None, } } } @@ -324,6 +423,35 @@ impl Accept { } } +/// A minimal Undo activity for a Follow. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Undo { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: UndoType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Undo { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: UndoType::default(), + id, + actor, + object, + } + } +} + /// A minimal Create activity for a concrete object type. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Create { @@ -334,6 +462,10 @@ pub struct Create { pub id: Iri, pub actor: Reference, pub object: Reference, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, } impl Create { @@ -349,6 +481,39 @@ impl Create { id, actor, object, + to: References::new(), + cc: References::new(), + } + } +} + +/// A minimal ActivityStreams ordered collection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OrderedCollection { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: OrderedCollectionType, + pub id: Iri, + #[serde(rename = "totalItems")] + pub total_items: u64, + #[serde(rename = "orderedItems")] + pub ordered_items: Vec, +} + +impl OrderedCollection { + #[must_use] + pub fn new(id: Iri, total_items: u64, ordered_items: Vec) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: OrderedCollectionType::default(), + id, + total_items, + ordered_items, } } } @@ -360,6 +525,18 @@ mod tests { use serde::de::DeserializeOwned; use serde_json::json; + #[test] + fn cryptographic_key_defaults_missing_type() { + let key: CryptographicKey = serde_json::from_value(serde_json::json!({ + "id": "https://example.com/users/alice#main-key", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" + })) + .expect("deserialize key without type"); + + assert_eq!(key.kind, CryptographicKeyType::CryptographicKey); + } + fn roundtrip(value: &T) -> T where T: DeserializeOwned + Serialize, @@ -454,11 +631,13 @@ mod tests { note.content = Some("Hello, fediverse.".to_string()); note.published = Some("2026-05-29T06:30:00Z".to_string()); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!(roundtrip(&create), create); } diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/activitypub_serialization.rs similarity index 61% rename from crates/feder-vocab/tests/phase1_shapes.rs rename to crates/feder-vocab/tests/activitypub_serialization.rs index 043f477..ba1bf3a 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -14,11 +14,12 @@ // along with this program. If not, see . use feder_vocab::{ - ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference, References, + ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, + OrderedCollection, Reference, References, SECURITY_CONTEXT, Undo, }; use serde_json::{Value, json}; -fn serialize(value: impl serde::Serialize) -> Value { +fn serialize_to_value(value: impl serde::Serialize) -> Value { serde_json::to_value(value).expect("serialize vocab value") } @@ -42,6 +43,38 @@ fn incoming_follow_json() -> serde_json::Value { }) } +#[test] +fn actor_serializes_embedded_cryptographic_key() { + let actor_id = iri("https://example.com/users/alice"); + let mut actor = Actor::person( + actor_id.clone(), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), + ); + actor.set_public_key(Reference::object(CryptographicKey::new( + iri("https://example.com/users/alice#main-key"), + actor_id, + "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n".to_string(), + ))); + + assert_eq!( + serialize_to_value(actor), + json!({ + "@context": [ACTIVITYSTREAMS_CONTEXT, SECURITY_CONTEXT], + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "publicKey": { + "id": "https://example.com/users/alice#main-key", + "type": "CryptographicKey", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n" + } + }) + ); +} + #[test] fn follow_activity_accepts_id_or_embedded_actor_references() { let follow: Follow = @@ -68,7 +101,7 @@ fn accept_activity_can_embed_follow_activity() { ); assert_eq!( - serialize(outgoing_accept), + serialize_to_value(outgoing_accept), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Accept", @@ -92,39 +125,99 @@ fn accept_activity_can_embed_follow_activity() { } #[test] -fn local_note_can_shape_create_note_activity() { +fn undo_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + let undo = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::object(follow), + ); + + assert_eq!( + serialize_to_value(undo), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Undo", + "id": "https://remote.example/activities/undo/1", + "actor": "https://remote.example/users/bob", + "object": incoming_follow_json() + }) + ); +} + +#[test] +fn ordered_collection_serializes_actor_iris() { + let collection = OrderedCollection::new( + iri("https://example.com/users/alice/followers"), + 2, + vec![ + iri("https://remote.example/users/bob"), + iri("https://another.example/users/carol"), + ], + ); + + assert_eq!( + serialize_to_value(collection), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "OrderedCollection", + "id": "https://example.com/users/alice/followers", + "totalItems": 2, + "orderedItems": [ + "https://remote.example/users/bob", + "https://another.example/users/carol" + ] + }) + ); +} + +#[test] +fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.cc = References::one(iri("https://example.com/users/alice/followers")); note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); note.published = Some("2026-06-02T00:00:00Z".to_string()); + note.url = Some(iri("https://example.com/@alice/1")); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!( - serialize(create), + serialize_to_value(create), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Create", "id": "https://example.com/activities/create/1", "actor": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "object": { "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Note", "id": "https://example.com/notes/1", "attributedTo": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "content": "Hello from Feder.", - "published": "2026-06-02T00:00:00Z" + "mediaType": "text/html", + "published": "2026-06-02T00:00:00Z", + "url": "https://example.com/@alice/1" } }) ); } #[test] -fn reference_keeps_id_and_embedded_object_shapes_distinct() { +fn reference_deserializes_id_and_embedded_object_distinctly() { let id_reference: Reference = serde_json::from_value(json!("https://example.com/notes/1")) .expect("deserialize id reference"); @@ -141,7 +234,7 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { } #[test] -fn references_can_represent_common_recipient_shapes() { +fn references_deserialize_single_and_multiple_recipients() { let single: References = serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) .expect("deserialize single recipient"); diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..2f26f15 --- /dev/null +++ b/cspell.json @@ -0,0 +1,7 @@ +{ + "words": [ + "Feder", + "activitypub", + "webfinger" + ] +} \ No newline at end of file diff --git a/examples/single-user-server/Cargo.toml b/examples/single-user-server/Cargo.toml index 5cac0c3..8962f38 100644 --- a/examples/single-user-server/Cargo.toml +++ b/examples/single-user-server/Cargo.toml @@ -1,12 +1,16 @@ [package] name = "single-user-server" +publish = false version.workspace = true edition.workspace = true license.workspace = true [dependencies] axum = "0.8" -feder-runtime-server = { path = "../../crates/feder-runtime-server" } +feder-vocab.workspace = true +rand_core.workspace = true +feder-core = { workspace = true, features = ["http-signatures"] } +feder-server.workspace = true tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index 3dbd0b1..f9a4aec 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -1,36 +1,21 @@ Single-User Server Example ========================== -Demo app using `feder-runtime-server` with one hardcoded local actor. - -The example chooses concrete runtime values for the reusable server crate: - - - actor: `http://127.0.0.1:3000/users/alice` - - bind address: `127.0.0.1:3000` - - storage: in-memory SQLite - - inbox auth policy: unsigned requests allowed for local development +Demo app using `feder-server` with one hardcoded local actor and +the built-in SQLite storage adapter. Run --- -On Unix shells: +The example currently targets Linux: ~~~~ sh RUST_LOG=info cargo run -p single-user-server ~~~~ -On PowerShell: - -~~~~ powershell -$env:RUST_LOG = "info"; cargo run -p single-user-server -~~~~ - -On cmd.exe: - -~~~~ bat -set RUST_LOG=info && cargo run -p single-user-server -~~~~ +The server stores its actor signing identity, followers, outbound Follow +state, and Notes in `feder.sqlite3`. Set `FEDER_DATABASE` to use another path. The demo actor is: @@ -44,24 +29,17 @@ The server listens on: 127.0.0.1:3000 ~~~~ -Check the process: +Fetch the actor document: ~~~~ sh -curl -i http://127.0.0.1:3000/healthz -~~~~ - -Expected response: - -~~~~ text -HTTP/1.1 204 No Content +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice ~~~~ -Fetch the local actor: +Discover the actor through WebFinger: ~~~~ sh -curl -i http://127.0.0.1:3000/users/alice +curl -i \ + 'http://127.0.0.1:3000/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000' ~~~~ - -Supported `Follow` activities posted to `/users/alice/inbox` are parsed by the -runtime, decided by `feder-core`, and applied to in-memory storage. Other -activity types are currently accepted and ignored. diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index 89ed860..f478bcf 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,46 +13,107 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_runtime_server::{Error, InboxAuthPolicy, RuntimeConfig, StorageConfig, build_router}; +use std::{convert::Infallible, env, error::Error, net::SocketAddr, path::PathBuf}; -fn default_local() -> RuntimeConfig { - RuntimeConfig { - bind: "127.0.0.1:3000" +use feder_core::{ActorDispatcher, key::ActorKeyPair}; +use feder_server::{ + FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, +}; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use rand_core::OsRng; + +const IDENTIFIER: &str = "alice"; +const ORIGIN: &str = "http://127.0.0.1:3000"; +const HANDLE_HOST: &str = "127.0.0.1:3000"; +const DEFAULT_DATABASE_PATH: &str = "feder.sqlite3"; + +struct SingleActorDispatcher { + actor: Actor, +} + +impl ActorDispatcher for SingleActorDispatcher { + type Error = Infallible; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) + } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) + } +} + +fn local_actor(key_pair: &ActorKeyPair) -> Actor { + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); + let mut actor = Actor::person( + actor_id.parse().expect("valid actor IRI"), + format!("{actor_id}/inbox") .parse() - .expect("valid default bind address"), - actor_id: "http://127.0.0.1:3000/users/alice" + .expect("valid inbox IRI"), + format!("{actor_id}/outbox") .parse() - .expect("valid default actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" + .expect("valid outbox IRI"), + ); + actor.preferred_username = Some(IDENTIFIER.to_string()); + actor.name = Some("Alice".to_string()); + actor.followers = Some( + format!("{actor_id}/followers") .parse() - .expect("valid default inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" + .expect("valid followers collection IRI"), + ); + actor.endpoints = Some(Endpoints { + shared_inbox: Some( + format!("{ORIGIN}/inbox") + .parse() + .expect("valid shared inbox IRI"), + ), + }); + actor.set_public_key(Reference::object(CryptographicKey::new( + format!("{actor_id}#main-key") .parse() - .expect("valid default outbox IRI"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, - } + .expect("valid actor key IRI"), + actor.id.clone(), + key_pair.public_key_pem().to_string(), + ))); + actor } #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let config = default_local(); - let bind = config.bind; - let actor_id = config.actor_id.clone(); - let app = build_router(config)?; + let database_path = env::var_os("FEDER_DATABASE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_DATABASE_PATH)); + let storage = SqliteStore::open(&database_path)?; + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") + .parse() + .expect("valid actor IRI"); + let actor_key_pair = storage.load_or_generate_actor_key_pair(&actor_id, &mut OsRng)?; + let dispatcher = SingleActorDispatcher { + actor: local_actor(&actor_key_pair), + }; + let server = FederServer::with_outbound_address_policy( + dispatcher, + storage, + HANDLE_HOST, + OutboundAddressPolicy::AllowPrivateAddress, + )? + .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); + let app = build_router(server); + let bind: SocketAddr = "127.0.0.1:3000".parse()?; - tracing::info!(bind = %bind, actor = %actor_id, "starting Feder single-user example"); + tracing::info!( + bind = %bind, + actor = %actor_id, + database = %database_path.display(), + "starting Feder single-user example" + ); - let listener = tokio::net::TcpListener::bind(bind) - .await - .map_err(Error::Bind)?; - axum::serve(listener, app).await.map_err(Error::Serve)?; + let listener = tokio::net::TcpListener::bind(bind).await?; + axum::serve(listener, app).await?; Ok(()) } diff --git a/mise.toml b/mise.toml index 469d5f3..1bf199d 100644 --- a/mise.toml +++ b/mise.toml @@ -17,8 +17,8 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" [tasks.check] description = "Run type check, lint, and check Markdown format" run = [ - "cargo check", - "cargo clippy", + "cargo check --workspace --all-targets --all-features", + "cargo clippy --workspace --all-targets --all-features", "cargo fmt --check", "hongdown --check", "mise fmt --check", @@ -30,7 +30,7 @@ run = ["cargo fmt", "hongdown --write", "mise fmt"] [tasks.test] description = "Run the Rust tests" -run = "cargo test --workspace" +run = "cargo test --workspace --all-features" [tasks.bump] description = "Preview a workspace version bump" @@ -52,7 +52,9 @@ let dependency_version = ($publish_version | split row "+" | first) let crate_names = ( cargo metadata --no-deps --format-version 1 | from json - | get packages.name + | get packages + | where {|package| $package.publish != [] } + | get name ) let manifest = (