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 3776a94..b4f230b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,7 +271,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -280,6 +280,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -292,19 +302,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "feder-core" version = "0.1.0" dependencies = [ "base64", "feder-vocab", - "rand_chacha", "rsa", "zeroize", ] [[package]] -name = "feder-runtime-server" +name = "feder-server" version = "0.1.0" dependencies = [ "axum", @@ -312,7 +327,6 @@ dependencies = [ "feder-vocab", "httpdate", "ipnet", - "iri-string", "mime", "percent-encoding", "rand_core 0.6.4", @@ -320,6 +334,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "tower", @@ -714,7 +729,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -733,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -789,6 +804,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1240,6 +1261,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" @@ -1401,7 +1435,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1497,7 +1531,10 @@ 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", @@ -1576,6 +1613,17 @@ 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" @@ -1593,27 +1641,40 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[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]] @@ -1673,7 +1734,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1752,7 +1813,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1921,7 +1982,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2085,7 +2146,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2106,7 +2167,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2126,7 +2187,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2166,7 +2227,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 492eb2c..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" @@ -20,7 +20,7 @@ repository = "https://github.com/fedify-dev/feder" # 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 } diff --git a/README.md b/README.md index 9ea8f04..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 federation behavior such as inbox/outbox state, delivery -decisions, and protocol-level rules. 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 ab5bb5b..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 @@ -17,8 +17,10 @@ feder-vocab.workspace = true rsa = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } -[dev-dependencies] -rand_chacha.workspace = true - [lints] workspace = true + +[[test]] +name = "key" +path = "tests/key.rs" +required-features = ["http-signatures"] 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/http_signatures.rs b/crates/feder-core/src/key.rs similarity index 57% rename from crates/feder-core/src/http_signatures.rs rename to crates/feder-core/src/key.rs index d06d56f..d03f0ed 100644 --- a/crates/feder-core/src/http_signatures.rs +++ b/crates/feder-core/src/key.rs @@ -1,4 +1,17 @@ -//! Draft-Cavage HTTP Signature primitives. +// 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; @@ -16,7 +29,6 @@ use zeroize::Zeroizing; const ACTOR_RSA_BITS: usize = 4096; -/// A local actor's RSA key pair encoded for persistent storage. #[derive(Clone, Eq, PartialEq)] pub struct ActorKeyPair { private_key_pem: Zeroizing, @@ -24,7 +36,6 @@ pub struct ActorKeyPair { } impl ActorKeyPair { - /// Loads a persisted key pair and checks that both keys belong together. 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 = @@ -63,7 +74,26 @@ impl fmt::Debug for ActorKeyPair { } } -/// Errors produced while generating, encoding, or loading actor keys. +/// 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), @@ -89,26 +119,6 @@ impl fmt::Display for KeyError { impl core::error::Error for KeyError {} -/// 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, - }) -} - /// Creates an RFC 3230 SHA-256 digest header. #[must_use] pub fn create_sha256_digest_header(body: &[u8]) -> String { @@ -227,152 +237,3 @@ impl fmt::Display for HttpSignatureVerificationError { } impl core::error::Error for HttpSignatureVerificationError {} - -#[cfg(test)] -mod tests { - use alloc::string::ToString; - - use rand_chacha::ChaCha20Rng; - use rsa::rand_core::SeedableRng; - use rsa::traits::PublicKeyParts; - use rsa::{ - pkcs1v15::{Signature, VerifyingKey}, - signature::Verifier, - }; - - use super::*; - - const PRIVATE_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-private-key.pem"); - const PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-public-key.pem"); - const OTHER_PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-other-public-key.pem"); - - #[test] - fn generated_actor_key_pair_uses_4096_bit_rsa() { - let mut rng = test_rng(1); - let pair = generate_actor_key_pair(&mut rng).expect("generate actor key pair"); - let private_key = RsaPrivateKey::from_pkcs8_pem(pair.private_key_pem()) - .expect("parse generated private key"); - let public_key = RsaPublicKey::from_public_key_pem(pair.public_key_pem()) - .expect("parse generated public key"); - - assert_eq!(private_key.n().bits(), ACTOR_RSA_BITS); - assert_eq!(public_key.n().bits(), ACTOR_RSA_BITS); - assert_eq!(RsaPublicKey::from(&private_key), public_key); - } - - #[test] - fn persisted_actor_key_pair_rejects_mismatched_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 actor_key_pair_debug_output_redacts_private_key() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - let debug = alloc::format!("{pair:?}"); - - assert!(debug.contains("[REDACTED]")); - assert!(!debug.contains(pair.private_key_pem())); - } - - #[test] - fn sha256_digest_matches_known_vector() { - assert_eq!( - create_sha256_digest_header(b"Hello, world!"), - "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" - ); - } - - #[test] - fn draft_cavage_signature_preserves_header_order() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - let headers = [ - ("accept", "text/plain"), - ("content-type", "text/plain; charset=utf-8"), - ("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", "/", &headers) - .expect("sign request"); - - assert!(signature_header.starts_with( - "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) accept content-type date digest host\",signature=\"" - )); - let signature_prefix = signature_header_prefix(&headers); - let signature = signature_header - .strip_prefix(&signature_prefix) - .and_then(|value| value.strip_suffix('"')) - .expect("signature parameter"); - let signature = STANDARD.decode(signature).expect("base64 signature"); - let signature = Signature::try_from(signature.as_slice()).expect("RSA signature"); - let public_key = - RsaPublicKey::from_public_key_pem(pair.public_key_pem()).expect("parse public key"); - let verifying_key = VerifyingKey::::new(public_key); - let signature_base = draft_cavage_signature_base("POST", "/", &headers); - - verifying_key - .verify(signature_base.as_bytes(), &signature) - .expect("verify signature"); - } - - #[test] - fn draft_cavage_signature_verifies_and_rejects_changed_headers() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - 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() - ); - } - - fn signature_header_prefix(headers: &[(&str, &str)]) -> String { - let signed_headers = headers - .iter() - .map(|(name, _)| name.to_ascii_lowercase()) - .collect::>() - .join(" "); - format!( - "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"" - ) - } - - fn test_rng(seed: u8) -> ChaCha20Rng { - ChaCha20Rng::from_seed([seed; 32]) - } -} diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 449e123..219e0ba 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -13,954 +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::{string::String, vec::Vec}; - pub use feder_vocab as vocab; +use feder_vocab::{Actor, Iri}; +pub mod follow; #[cfg(feature = "http-signatures")] -pub mod http_signatures; - -pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; - -/// Portable core state and decision logic. -#[derive(Debug)] -pub struct FederCore { - state: FederState, -} - -impl FederCore { - #[must_use] - pub fn new(config: FederConfig) -> Self { - Self { - state: FederState::new(config), - } - } - - #[must_use] - pub fn state(&self) -> &FederState { - &self.state - } - - /// Handle one core input and return runtime actions to perform later. - /// - /// This method intentionally performs no I/O. Returned actions describe - /// work for a runtime or test harness to perform later. - #[must_use] - pub fn handle(&mut self, input: Input) -> HandleResult { - match input { - Input::ReceivedFollow(input) => { - let actions = self.state.record_follow(input); - HandleResult::new(actions) - } - Input::ReceivedUndoFollow(input) => { - let actions = self.state.record_undo_follow(input); - HandleResult::new(actions) - } - Input::UserCreateNote(input) => { - let actions = self.state.record_created_note(input); - HandleResult::new(actions) - } - } - } -} - -/// Runtime-provided configuration for portable core state. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FederConfig { - pub local_actor: vocab::Actor, -} - -impl FederConfig { - #[must_use] - pub fn new(local_actor: vocab::Actor) -> Self { - Self { local_actor } - } -} - -/// In-memory state used by portable core flows. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FederState { - local_actor: vocab::Actor, - followers: Vec, - objects: Vec, - activities: Vec, -} - -impl FederState { - #[must_use] - pub fn new(config: FederConfig) -> Self { - Self { - local_actor: config.local_actor, - followers: Vec::new(), - objects: Vec::new(), - activities: Vec::new(), - } - } - - #[must_use] - pub fn local_actor(&self) -> &vocab::Actor { - &self.local_actor - } - - #[must_use] - pub fn followers(&self) -> &[Follower] { - &self.followers - } - - #[must_use] - pub fn objects(&self) -> &[Object] { - &self.objects - } - - #[must_use] - pub fn activities(&self) -> &[Activity] { - &self.activities - } - - fn record_follow(&mut self, input: ReceivedFollow) -> Vec { - let follow = input.follow; - let Some(following) = reference_id(&follow.object) else { - return Vec::new(); - }; - - if following != &self.local_actor.id { - return Vec::new(); - } - - let Some(follower) = reference_id(&follow.actor).cloned() else { - return Vec::new(); - }; - - let relation = Follower { - follower: follower.clone(), - following: following.clone(), - }; - let mut actions = Vec::new(); - - if !self.followers.contains(&relation) { - self.followers.push(relation.clone()); - } - - actions.push(Action::StoreFollower(StoreFollower { - follower: follow.actor.clone(), - following: follow.object.clone(), - })); - - let inbox = match &follow.actor { - vocab::Reference::Object(actor) => Some(actor.inbox.clone()), - vocab::Reference::Id(_) => None, - }; - - if let Some(inbox) = inbox { - let accept = vocab::Accept::new( - input.accept_id, - vocab::Reference::id(self.local_actor.id.clone()), - vocab::Reference::object(follow), - ); - - actions.push(Action::SendActivity(SendActivity { - activity: Activity::Accept(accept), - recipients: Recipients::Inbox(inbox), - })); - } - - actions - } - - fn record_undo_follow(&mut self, input: ReceivedUndoFollow) -> Vec { - let undo = input.undo; - let Some(undo_actor) = reference_id(&undo.actor) else { - return Vec::new(); - }; - let vocab::Reference::Object(follow) = undo.object else { - return Vec::new(); - }; - let Some(follower) = reference_id(&follow.actor) else { - return Vec::new(); - }; - let Some(following) = reference_id(&follow.object) else { - return Vec::new(); - }; - - if undo_actor != follower || following != &self.local_actor.id { - return Vec::new(); - } - - let relation = Follower { - follower: follower.clone(), - following: following.clone(), - }; - self.followers.retain(|existing| existing != &relation); - - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: follower.clone(), - following: following.clone(), - })]) - } - - fn record_created_note(&mut self, input: UserCreateNote) -> Vec { - let Some(actor) = reference_id(&input.actor) else { - return Vec::new(); - }; - - if actor != &self.local_actor.id { - return Vec::new(); - } - - let actor = vocab::Reference::id(self.local_actor.id.clone()); - - let mut note = vocab::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 create = vocab::Create::new( - input.create_id, - actor, - vocab::Reference::object(note.clone()), - ); - create.to = note.to.clone(); - create.cc = note.cc.clone(); - - let recipients = note_recipients(&self.local_actor, ¬e); - - let object = Object::Note(note); - self.objects.push(object.clone()); - self.activities.push(Activity::CreateNote(create.clone())); - - let mut actions = Vec::new(); - - actions.push(Action::StoreObject(StoreObject { object })); - - for recipient in recipients { - actions.push(Action::SendActivity(SendActivity { - activity: Activity::CreateNote(create.clone()), - recipients: recipient, - })); - } - - actions - } -} - -fn note_recipients(local_actor: &vocab::Actor, note: &vocab::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 { - // Public describes visibility. It cannot receive an activity. - continue; - } else if local_actor.followers.as_ref() == Some(address) { - Recipients::Followers(local_actor.id.clone()) - } else if address == &local_actor.id { - continue; - } else { - Recipients::Actor(address.clone()) - }; - - if !recipients.contains(&recipient) { - recipients.push(recipient); - } - } - recipients -} - -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 - } -} - -/// Something entering the portable core from a runtime. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Input { - ReceivedFollow(ReceivedFollow), - ReceivedUndoFollow(ReceivedUndoFollow), - UserCreateNote(UserCreateNote), -} - -/// Runtime-provided data for handling a received Follow. -/// -/// The Accept activity ID is an input so the core does not depend on clocks, -/// randomness, or platform-specific ID generation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReceivedFollow { - pub follow: vocab::Follow, - pub accept_id: vocab::Iri, -} - -/// Runtime-provided data for handling a received Undo of a Follow. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReceivedUndoFollow { - pub undo: vocab::Undo, -} - -/// Runtime-provided data for creating a local note. -/// -/// IDs and timestamps are inputs so the core does not depend on clocks, -/// randomness, or platform-specific ID generation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UserCreateNote { - pub note_id: vocab::Iri, - pub create_id: vocab::Iri, - pub actor: vocab::Reference, - pub to: vocab::References, - pub cc: vocab::References, - pub content: String, - pub media_type: Option, - pub published: Option, - pub url: Option, -} - -impl Input { - pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { - Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) - } - - pub fn received_undo_follow(undo: vocab::Undo) -> Self { - Self::ReceivedUndoFollow(ReceivedUndoFollow { undo }) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Follower { - pub follower: vocab::Iri, - pub following: vocab::Iri, -} - -/// Something the runtime should perform after core handling. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Action { - StoreFollower(StoreFollower), - RemoveFollower(RemoveFollower), - StoreObject(StoreObject), - SendActivity(SendActivity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreFollower { - pub follower: vocab::Reference, - pub following: vocab::Reference, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RemoveFollower { - /// The remote actor ending the follower relation. - pub follower: vocab::Iri, - /// The local actor that was followed. - pub following: vocab::Iri, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreObject { - pub object: Object, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SendActivity { - pub activity: Activity, - pub recipients: Recipients, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Recipients { - /// Deliver directly to this inbox. - Inbox(vocab::Iri), - /// Deliver to the current followers of this local actor. - Followers(vocab::Iri), - Actor(vocab::Iri), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Activity { - Accept(vocab::Accept), - CreateNote(vocab::Create), - Follow(vocab::Follow), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Object { - Note(vocab::Note), -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct HandleResult { - pub actions: Vec, -} - -impl HandleResult { - #[must_use] - pub fn new(actions: Vec) -> Self { - Self { actions } - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.actions.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::format; - use alloc::string::ToString; - - fn iri(value: &str) -> vocab::Iri { - value.parse().expect("valid test IRI") - } - - fn actor(id: &str) -> vocab::Actor { - vocab::Actor::person( - iri(id), - iri(&format!("{id}/inbox")), - iri(&format!("{id}/outbox")), - ) - } - - fn core() -> FederCore { - let mut local_actor = actor("https://example.com/users/alice"); - local_actor.followers = Some(iri("https://example.com/users/alice/followers")); - FederCore::new(FederConfig::new(local_actor)) - } - - fn received_follow(follow: vocab::Follow, id: &str) -> Input { - Input::ReceivedFollow(ReceivedFollow { - follow, - accept_id: iri(id), - }) - } - - fn received_undo_follow(follow: vocab::Follow, actor_id: &str) -> Input { - Input::received_undo_follow(vocab::Undo::new( - iri("https://remote.example/activities/undo/1"), - vocab::Reference::id(iri(actor_id)), - vocab::Reference::object(follow), - )) - } - - #[test] - fn core_is_created_with_local_actor_state() { - let core = core(); - - assert_eq!( - core.state().local_actor().id, - iri("https://example.com/users/alice") - ); - assert!(core.state().followers().is_empty()); - assert!(core.state().objects().is_empty()); - assert!(core.state().activities().is_empty()); - } - - #[test] - fn received_follow_records_follower_and_emits_accept_actions() { - let mut core = core(); - let follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - - let result = core.handle(received_follow( - follow, - "https://example.com/activities/accept/1", - )); - - assert_eq!(result.actions.len(), 2); - assert_eq!( - core.state().followers(), - &[Follower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - }] - ); - assert_eq!( - result.actions[0], - Action::StoreFollower(StoreFollower { - follower: vocab::Reference::object(actor("https://remote.example/users/bob")), - following: vocab::Reference::id(iri("https://example.com/users/alice")), - }) - ); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected SendActivity action"); - }; - assert_eq!( - send.recipients, - Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) - ); - - let Activity::Accept(accept) = &send.activity else { - panic!("expected 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")) - ); - let vocab::Reference::Object(accepted_follow) = &accept.object else { - panic!("expected embedded Follow object"); - }; - assert_eq!( - accepted_follow.id, - iri("https://remote.example/activities/follow/1") - ); - } - - #[test] - fn received_follow_refreshes_the_stored_follower_actor() { - let mut core = core(); - let first_follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - - let mut updated_actor = actor("https://remote.example/users/bob"); - updated_actor.inbox = iri("https://remote.example/inboxes/bob"); - let second_follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/2"), - vocab::Reference::object(updated_actor), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - - let first_result = core.handle(received_follow( - first_follow, - "https://example.com/activities/accept/1", - )); - let second_result = core.handle(received_follow( - second_follow, - "https://example.com/activities/accept/2", - )); - - assert_eq!(first_result.actions.len(), 2); - assert_eq!(second_result.actions.len(), 2); - assert_eq!( - second_result.actions[0], - Action::StoreFollower(StoreFollower { - follower: vocab::Reference::object({ - let mut actor = actor("https://remote.example/users/bob"); - actor.inbox = iri("https://remote.example/inboxes/bob"); - actor - }), - following: vocab::Reference::id(iri("https://example.com/users/alice")), - }) - ); - - let Action::SendActivity(send) = &second_result.actions[1] else { - panic!("expected SendActivity action"); - }; - assert_eq!( - send.recipients, - Recipients::Inbox(iri("https://remote.example/inboxes/bob")) - ); - - let Activity::Accept(accept) = &send.activity else { - panic!("expected Accept activity"); - }; - assert_eq!(accept.id, iri("https://example.com/activities/accept/2")); - - assert_eq!( - core.state().followers(), - &[Follower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - }] - ); - } - - #[test] - fn received_follow_with_actor_id_records_follower_without_accept_delivery() { - let mut core = core(); - let 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")), - ); - - let result = core.handle(received_follow( - follow, - "https://example.com/activities/accept/1", - )); - - assert_eq!( - result.actions, - Vec::from([Action::StoreFollower(StoreFollower { - follower: vocab::Reference::id(iri("https://remote.example/users/bob")), - following: vocab::Reference::id(iri("https://example.com/users/alice")), - })]) - ); - assert_eq!( - core.state().followers(), - &[Follower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - }] - ); - } - - #[test] - fn received_follow_for_other_actor_is_ignored() { - let mut core = core(); - let follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/other")), - ); - - let result = core.handle(received_follow( - follow, - "https://example.com/activities/accept/1", - )); - - assert!(result.is_empty()); - assert!(core.state().followers().is_empty()); - } - - #[test] - fn received_undo_follow_removes_follower() { - let mut core = core(); - let follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - let _ = core.handle(received_follow( - follow.clone(), - "https://example.com/activities/accept/1", - )); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/bob", - )); - - assert_eq!( - result.actions, - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - ); - assert!(core.state().followers().is_empty()); - } - - #[test] - fn received_undo_follow_rejects_actor_that_does_not_own_follow() { - let mut core = core(); - let follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - let _ = core.handle(received_follow( - follow.clone(), - "https://example.com/activities/accept/1", - )); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/mallory", - )); - - assert!(result.is_empty()); - assert_eq!(core.state().followers().len(), 1); - } - - #[test] - fn received_undo_follow_emits_idempotent_removal_action() { - let 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")), - ); - let mut core = core(); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/bob", - )); - - assert_eq!( - result.actions, - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - ); - } - - #[test] - fn user_create_note_records_object_and_emits_followers_delivery() { - let input = UserCreateNote { - note_id: iri("https://example.com/notes/1"), - create_id: iri("https://example.com/activities/create/1"), - actor: vocab::Reference::id(iri("https://example.com/users/alice")), - to: vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), - cc: vocab::References::one(iri("https://example.com/users/alice/followers")), - content: "Hello from Feder.".to_string(), - media_type: Some("text/html".to_string()), - published: Some("2026-06-10T00:00:00Z".to_string()), - url: Some(iri("https://example.com/@alice/1")), - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 2); - assert_eq!(core.state().objects().len(), 1); - assert_eq!(core.state().activities().len(), 1); - - let Object::Note(note) = &core.state().objects()[0]; - assert_eq!(note.id, iri("https://example.com/notes/1")); - assert_eq!( - note.attributed_to, - Some(vocab::Reference::id(iri("https://example.com/users/alice"))) - ); - assert_eq!(note.content, Some("Hello from Feder.".to_string())); - assert_eq!( - note.to, - vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")) - ); - assert_eq!( - note.cc, - vocab::References::one(iri("https://example.com/users/alice/followers")) - ); - assert_eq!(note.media_type.as_deref(), Some("text/html")); - assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); - assert_eq!(note.url, Some(iri("https://example.com/@alice/1"))); - - match &core.state().activities()[0] { - Activity::CreateNote(create) => { - assert_eq!(create.id, iri("https://example.com/activities/create/1")); - assert_eq!( - create.actor, - vocab::Reference::id(iri("https://example.com/users/alice")) - ); - assert_eq!(create.to, note.to); - assert_eq!(create.cc, note.cc); - } - Activity::Accept(_) | Activity::Follow(_) => { - panic!("expected Create activity") - } - } - - assert_eq!( - result.actions[0], - Action::StoreObject(StoreObject { - object: Object::Note(note.clone()), - }) - ); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected followers delivery action"); - }; - assert_eq!( - send.recipients, - Recipients::Followers(iri("https://example.com/users/alice")) - ); - let Activity::CreateNote(create) = &send.activity else { - panic!("expected Create activity"); - }; - assert_eq!(create.id, iri("https://example.com/activities/create/1")); - let vocab::Reference::Object(created_note) = &create.object else { - panic!("expected embedded Note object"); - }; - assert_eq!(created_note.id, iri("https://example.com/notes/1")); - } - - #[test] - fn mocked_core_flow_accepts_follow_then_delivers_created_note() { - let mut core = core(); - let follow = vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::object(actor("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ); - - let follow_result = core.handle(received_follow( - follow, - "https://example.com/activities/accept/1", - )); - - assert_eq!(follow_result.actions.len(), 2); - assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); - let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { - panic!("expected Accept delivery action"); - }; - assert_eq!( - accept_delivery.recipients, - Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) - ); - assert!(matches!(accept_delivery.activity, Activity::Accept(_))); - - let create_result = core.handle(Input::UserCreateNote(UserCreateNote { - note_id: iri("https://example.com/notes/1"), - create_id: iri("https://example.com/activities/create/1"), - actor: vocab::Reference::id(iri("https://example.com/users/alice")), - to: vocab::References::new(), - cc: vocab::References::one(iri("https://example.com/users/alice/followers")), - content: "Hello from Feder.".to_string(), - media_type: None, - published: Some("2026-06-10T00:00:00Z".to_string()), - url: None, - })); - - assert_eq!(create_result.actions.len(), 2); - assert!(matches!(create_result.actions[0], Action::StoreObject(_))); - let Action::SendActivity(create_delivery) = &create_result.actions[1] else { - panic!("expected followers delivery action"); - }; - assert_eq!( - create_delivery.recipients, - Recipients::Followers(iri("https://example.com/users/alice")) - ); - assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); - - assert_eq!(core.state().followers().len(), 1); - assert_eq!(core.state().objects().len(), 1); - assert_eq!(core.state().activities().len(), 1); - } - - #[test] - fn user_create_note_normalizes_embedded_local_actor_to_local_actor_id() { - let mut supplied_actor = actor("https://example.com/users/alice"); - supplied_actor.inbox = iri("https://untrusted.example/inbox"); - - let input = UserCreateNote { - note_id: iri("https://example.com/notes/1"), - create_id: iri("https://example.com/activities/create/1"), - actor: vocab::Reference::object(supplied_actor), - to: vocab::References::new(), - cc: vocab::References::new(), - content: "Hello from Feder.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 1); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - - let Object::Note(note) = &core.state().objects()[0]; - assert_eq!( - note.attributed_to, - Some(vocab::Reference::id(iri("https://example.com/users/alice"))) - ); - - let Activity::CreateNote(create) = &core.state().activities()[0] else { - panic!("expected Create activity"); - }; - assert_eq!( - create.actor, - vocab::Reference::id(iri("https://example.com/users/alice")) - ); - } - - #[test] - fn user_create_note_emits_one_direct_delivery_for_duplicate_actor_addresses() { - let bob = iri("https://remote.example/users/bob"); - let input = UserCreateNote { - note_id: iri("https://example.com/notes/1"), - create_id: iri("https://example.com/activities/create/1"), - actor: vocab::Reference::id(iri("https://example.com/users/alice")), - to: vocab::References::one(bob.clone()), - cc: vocab::References::one(bob.clone()), - content: "Hello Bob.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 2); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected direct delivery action"); - }; - assert_eq!(send.recipients, Recipients::Actor(bob)); - } - - #[test] - fn user_create_note_does_not_deliver_to_public_collection() { - let input = UserCreateNote { - note_id: iri("https://example.com/notes/1"), - create_id: iri("https://example.com/activities/create/1"), - actor: vocab::Reference::id(iri("https://example.com/users/alice")), - to: vocab::References::one(iri(PUBLIC_COLLECTION)), - cc: vocab::References::new(), - content: "Hello everyone.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 1); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - } - - #[test] - fn user_create_note_for_non_local_actor_is_ignored() { - let input = UserCreateNote { - note_id: iri("https://remote.example/notes/1"), - create_id: iri("https://remote.example/activities/create/1"), - actor: vocab::Reference::id(iri("https://remote.example/users/bob")), - to: vocab::References::new(), - cc: vocab::References::new(), - content: "Hello from elsewhere.".to_string(), - media_type: None, - published: Some("2026-06-10T00:00:00Z".to_string()), - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); +pub mod key; +pub mod note; +pub mod storage; +pub mod undo; - assert!(result.is_empty()); - assert!(core.state().objects().is_empty()); - assert!(core.state().activities().is_empty()); - } +pub trait ActorDispatcher { + type Error; - #[test] - fn handle_result_wraps_action_lists() { - let result = HandleResult::new(Vec::from([Action::StoreFollower(StoreFollower { - follower: vocab::Reference::id(iri("https://remote.example/users/bob")), - following: vocab::Reference::id(iri("https://example.com/users/alice")), - })])); + fn get_actor(&self, identifier: &str) -> Result, Self::Error>; - assert_eq!(result.actions.len(), 1); - } + 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/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/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/README.md b/crates/feder-runtime-server/README.md deleted file mode 100644 index a1da47c..0000000 --- a/crates/feder-runtime-server/README.md +++ /dev/null @@ -1,65 +0,0 @@ -Feder Runtime Server -==================== - -Reusable Axum/Tokio server integration for Feder. - -This crate builds an Axum router from caller-provided runtime configuration. -It provides a health check endpoint, WebFinger discovery, and a local actor -route with its followers collection. The caller chooses concrete bind -addresses, actor IRIs, usernames, and handle hosts. - -ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is -included. The runtime can use in-memory storage for tests and examples, or -file-backed SQLite storage for persisted follower state. Outgoing -`SendActivity` actions are sent synchronously to recipient inboxes as -ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox -requests can require verification with the same signature scheme. - - -Platform support ----------------- - -This runtime currently targets Linux for development and deployment. Other -platforms may compile, but they are not currently supported. - -On Unix targets, file-backed SQLite databases are created with owner-only -permissions, and existing database files are restricted to owner-only -permissions when opened. This protects the actor signing keys stored in the -database. Equivalent Windows ACL hardening is not currently implemented. - - -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/app.rs b/crates/feder-runtime-server/src/app.rs deleted file mode 100644 index db6b22c..0000000 --- a/crates/feder-runtime-server/src/app.rs +++ /dev/null @@ -1,125 +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::actor::ActorResolver; -use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::followers::followers; -use crate::object::get_object; -use crate::send::ActivitySender; -use crate::storage::{RuntimeStore, 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_core::{ - FederConfig, FederCore, - http_signatures::{ActorKeyPair, generate_actor_key_pair}, -}; -use feder_vocab::{Actor, CryptographicKey, Reference}; -use iri_string::types::IriFragmentStr; -use rand_core::OsRng; - -#[derive(Clone)] -pub struct AppState { - pub core: Arc>, - pub store: Arc>, - pub actor_key_pair: Arc, - pub actor_resolver: ActorResolver, - pub activity_sender: ActivitySender, - 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()); - actor.followers = Some( - format!("{}/followers", actor.id.as_str().trim_end_matches('/')) - .parse() - .expect("appending a followers path preserves a valid actor IRI"), - ); - - let mut store = match &config.storage { - StorageConfig::InMemory => SqliteStore::open_in_memory()?, - StorageConfig::Sqlite { path } => SqliteStore::open(path)?, - }; - let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { - Some(key_pair) => key_pair, - None => { - let key_pair = generate_actor_key_pair(&mut OsRng)?; - store.insert_actor_key_pair(&actor.id, &key_pair)?; - key_pair - } - }; - let mut key_id = actor.id.clone(); - key_id.set_fragment(Some( - IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), - )); - actor.set_public_key(Reference::object(CryptographicKey::new( - key_id.clone(), - actor.id.clone(), - actor_key_pair.public_key_pem().to_string(), - ))); - let core = FederCore::new(FederConfig::new(actor.clone())); - let actor_key_pair = Arc::new(actor_key_pair); - let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; - let activity_sender = ActivitySender::new( - actor_key_pair.clone(), - key_id.to_string(), - config.outbound_address_policy, - )?; - - Ok(Self { - core: Arc::new(Mutex::new(core)), - store: Arc::new(Mutex::new(store)), - actor_key_pair, - actor_resolver, - activity_sender, - 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_with_state(state)) -} - -pub fn router_with_state(state: AppState) -> Router { - Router::new() - .route("/healthz", get(healthz)) - .route("/.well-known/webfinger", get(webfinger)) - .route("/users/{username}", get(actor)) - .route("/users/{username}/followers", get(followers)) - .route("/users/{username}/posts/{id}", get(get_object)) - .route("/users/{username}/inbox", post(inbox)) - .layer(DefaultBodyLimit::max(1_048_576)) - .with_state(state) -} - -async fn healthz() -> StatusCode { - 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 8ee8725..0000000 --- a/crates/feder-runtime-server/src/config.rs +++ /dev/null @@ -1,53 +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, -} - -/// Controls which network addresses may receive outgoing activities. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum OutboundAddressPolicy { - /// Allows only publicly routable destination addresses. - #[default] - PublicOnly, - - /// Allows private and special-use destinations. This disables SSRF protection. - AllowPrivateAddress, -} - -#[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 outbound_address_policy: OutboundAddressPolicy, - pub storage: StorageConfig, -} diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs deleted file mode 100644 index b0f8ec3..0000000 --- a/crates/feder-runtime-server/src/error.rs +++ /dev/null @@ -1,41 +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 . - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("runtime core state is unavailable")] - CoreStateUnavailable, - - #[error("runtime storage state is unavailable")] - StorageStateUnavailable, - - #[error("failed to bind server socket")] - Bind(#[source] std::io::Error), - - #[error("server failed")] - Serve(#[source] std::io::Error), - - #[error("storage failed")] - Storage(#[from] crate::storage::StoreError), - - #[error("actor key generation failed")] - ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), - - #[error("activity sending failed")] - ActivitySender(#[from] crate::send::SendError), - - #[error("actor resolver setup failed")] - ActorResolver(#[from] crate::actor::ActorResolveError), -} diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs deleted file mode 100644 index 47fcc07..0000000 --- a/crates/feder-runtime-server/src/inbox.rs +++ /dev/null @@ -1,502 +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::{ - collections::{BTreeMap, HashSet}, - 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::{ - Input, - http_signatures::{create_sha256_digest_header, verify_draft_cavage}, -}; -use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; -use mime::Mime; -use serde_json::{Value, from_slice, from_value}; - -use crate::config::InboxAuthPolicy; -use crate::send::SendError; -use crate::{Error, app::AppState}; - -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"]; - -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) -} - -async fn verify_inbox_request( - app_state: &AppState, - req: &InboxRequest, - activity_actor_id: Option<&Iri>, -) -> Result, StatusCode> { - match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), - InboxAuthPolicy::RequireSigned => { - let activity_actor_id = activity_actor_id.ok_or(StatusCode::UNAUTHORIZED)?; - verify_signed_request(app_state, req, activity_actor_id) - .await - .map(Some) - } - } -} - -async fn verify_signed_request( - app_state: &AppState, - req: &InboxRequest, - activity_actor_id: &Iri, -) -> Result { - let signature_header = req - .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 = req.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( - &req.headers, - &app_state.handle_host, - app_state.local_actor.inbox.scheme_str(), - )?; - verify_request_date(&req.headers)?; - verify_request_digest(&req.headers, &req.body)?; - - let key_id: Iri = signature - .key_id - .parse() - .map_err(|_| StatusCode::UNAUTHORIZED)?; - let public_key = app_state - .actor_resolver - .resolve_key(&key_id) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - if public_key.owner != *activity_actor_id { - return Err(StatusCode::UNAUTHORIZED); - } - - let request_target = req - .uri - .path_and_query() - .map_or(req.uri.path(), |value| value.as_str()); - verify_draft_cavage( - &public_key.public_key_pem, - req.method.as_str(), - request_target, - &signed_headers, - &signature.signature, - ) - .map_err(|_| StatusCode::UNAUTHORIZED)?; - - let actor = app_state - .actor_resolver - .resolve(activity_actor_id) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - let actor_owns_key = match actor.public_key.as_ref() { - Some(Reference::Id(advertised_key_id)) => advertised_key_id == &public_key.id, - Some(Reference::Object(advertised_key)) => { - advertised_key.id == public_key.id - && advertised_key.owner == actor.id - && advertised_key.public_key_pem == public_key.public_key_pem - } - None => false, - }; - if !actor_owns_key { - return Err(StatusCode::UNAUTHORIZED); - } - - Ok(actor) -} - -fn verify_request_host( - headers: &HeaderMap, - expected_host: &str, - inbox_scheme: &str, -) -> 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 expected_host = expected_host - .parse::() - .ok() - .filter(|authority| !authority.as_str().contains('@')) - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - let default_port = if inbox_scheme.eq_ignore_ascii_case("http") { - Some(80) - } else if inbox_scheme.eq_ignore_ascii_case("https") { - Some(443) - } else { - 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 activity_actor_id(value: &Value) -> Option { - let actor = value.get("actor")?; - let actor_id = actor - .as_str() - .or_else(|| actor.get("id").and_then(Value::as_str))?; - actor_id.parse().ok() -} - -fn actor_reference_id(reference: &Reference) -> &Iri { - match reference { - Reference::Id(actor_id) => actor_id, - Reference::Object(actor) => &actor.id, - } -} - -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 -} - -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()) - .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 req = InboxRequest { - username, - headers, - method, - uri, - body, - }; - - let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; - let activity_actor_id = activity_actor_id(&value); - let verified_actor = verify_inbox_request(&app_state, &req, activity_actor_id.as_ref()).await?; - - let activity_type = value.get("type").and_then(|value| value.as_str()); - - let input = match activity_type { - Some("Follow") => { - let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - if actor_reference_id(&follow.object) != &app_state.local_actor.id { - return Ok(StatusCode::ACCEPTED.into_response()); - } - if let Some(actor) = verified_actor { - if actor_reference_id(&follow.actor) != &actor.id { - return Err(StatusCode::UNAUTHORIZED); - } - follow.actor = Reference::object(actor); - } else { - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - } - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - Input::received_follow(follow, accept_id) - } - Some("Undo") => { - if value - .get("object") - .and_then(|object| object.get("type")) - .and_then(Value::as_str) - != Some("Follow") - { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let Reference::Object(follow) = &undo.object else { - return Ok(StatusCode::ACCEPTED.into_response()); - }; - let undo_actor_id = actor_reference_id(&undo.actor); - if undo_actor_id != actor_reference_id(&follow.actor) { - return Err(StatusCode::UNAUTHORIZED); - } - if verified_actor - .as_ref() - .is_some_and(|actor| undo_actor_id != &actor.id) - { - return Err(StatusCode::UNAUTHORIZED); - } - if actor_reference_id(&follow.object) != &app_state.local_actor.id { - return Ok(StatusCode::ACCEPTED.into_response()); - } - Input::received_undo_follow(undo) - } - // Unsupported activity types will be ignored. - _ => return Ok(StatusCode::ACCEPTED.into_response()), - }; - - app_state - .handle_input(input) - .await - .map_err(|error| match error { - Error::ActivitySender( - SendError::PrivateInboxAddress { .. } - | SendError::Request(_) - | SendError::UnsuccessfulStatus { .. }, - ) => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - })?; - - Ok(StatusCode::ACCEPTED.into_response()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - fn headers_with_host(host: &'static str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(HOST, HeaderValue::from_static(host)); - headers - } - - #[test] - fn request_host_accepts_case_and_default_port_equivalence() { - assert_eq!( - verify_request_host( - &headers_with_host("EXAMPLE.COM:443"), - "example.com", - "https" - ), - Ok(()) - ); - } - - #[test] - fn request_host_rejects_wrong_or_invalid_authorities() { - for host in ["other.example", "example.com:8443", "example.com:99999"] { - assert_eq!( - verify_request_host(&headers_with_host(host), "example.com", "https"), - Err(StatusCode::UNAUTHORIZED), - "Host: {host}" - ); - } - } -} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs deleted file mode 100644 index 9fa5339..0000000 --- a/crates/feder-runtime-server/src/lib.rs +++ /dev/null @@ -1,33 +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 followers; -pub mod inbox; -mod negotiation; -pub mod object; -mod operation; -pub mod send; -pub mod storage; -mod url; -pub mod webfinger; - -pub use actor::{ActorResolveError, ActorResolver}; -pub use app::{AppState, build_router}; -pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; -pub use error::Error; diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs deleted file mode 100644 index 59c6471..0000000 --- a/crates/feder-runtime-server/src/operation.rs +++ /dev/null @@ -1,129 +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::collections::HashSet; - -use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; - -use crate::{Error, actor::ActorResolveError, app::AppState, storage::RuntimeStore}; - -impl AppState { - /// Create, persist, and deliver a Note initiated by the local application. - /// - /// Persistence occurs before delivery. Recipient resolution and delivery - /// continue independently after individual failures. If any attempt fails, - /// this returns an error while the created Note remains available from the - /// runtime store and successful deliveries remain completed. - pub async fn create_note(&self, input: UserCreateNote) -> Result { - self.handle_input(Input::UserCreateNote(input)).await - } - - pub(crate) async fn handle_input(&self, input: Input) -> Result { - let result = { - let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; - core.handle(input) - }; - { - let mut store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.persist_actions(&result.actions)?; - }; - let (deliveries, actor_resolve_error) = - self.resolve_outbound_deliveries(&result.actions).await?; - - self.activity_sender.send_actions(&deliveries).await?; - if let Some(error) = actor_resolve_error { - return Err(error.into()); - } - - Ok(result) - } - - async fn resolve_outbound_deliveries( - &self, - actions: &[Action], - ) -> Result<(Vec, Option), Error> { - let mut resolved = Vec::new(); - let mut first_actor_resolve_error = None; - let mut covered_actor_ids = HashSet::new(); - let mut seen_inboxes = HashSet::new(); - - // Expand followers first so direct recipients already covered by - // follower delivery are not also sent to their personal inbox. - for action in actions { - let Action::SendActivity(send) = action else { - continue; - }; - let Recipients::Followers(actor_id) = &send.recipients else { - continue; - }; - let recipients = { - let store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.list_follower_recipients(actor_id)? - }; - for recipient in recipients { - covered_actor_ids.insert(recipient.actor_id); - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), - }); - } - } - } - - for action in actions { - let Action::SendActivity(send) = action else { - continue; - }; - match &send.recipients { - Recipients::Inbox(inbox) => { - if seen_inboxes.insert(inbox.clone()) { - resolved.push(send.clone()); - } - } - Recipients::Followers(_) => {} - Recipients::Actor(actor_id) => { - if covered_actor_ids.contains(actor_id) { - continue; - } - match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => { - covered_actor_ids.insert(actor_id.clone()); - if seen_inboxes.insert(actor.inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }); - } - } - Err(error) if first_actor_resolve_error.is_none() => { - first_actor_resolve_error = Some(error); - } - Err(_) => {} - } - } - } - } - - Ok((resolved, first_actor_resolve_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 b3757c6..0000000 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ /dev/null @@ -1,80 +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::{ - Action, Object, - http_signatures::{ActorKeyPair, KeyError}, -}; -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("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 runtime object type")] - UnsupportedObjectType, - - #[error("unsupported stored object type: {0}")] - UnsupportedStoredObjectType(String), - - #[error(transparent)] - ActorKey(#[from] KeyError), -} - -pub trait RuntimeStore { - fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError>; - - fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; - - fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; - - fn load_object(&self, object_id: &Iri) -> Result, StoreError>; - - fn insert_actor_key_pair( - &mut self, - actor_id: &Iri, - key_pair: &ActorKeyPair, - ) -> Result<(), StoreError>; - - fn load_actor_key_pair(&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 682e4db..0000000 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ /dev/null @@ -1,1087 +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; - -#[cfg(unix)] -use std::{ - fs::OpenOptions, - os::unix::fs::{OpenOptionsExt, PermissionsExt}, -}; - -use feder_core::{Action, Object, http_signatures::ActorKeyPair}; -use feder_vocab::{Actor, Iri, Note, Reference}; -use rusqlite::{Connection, OptionalExtension, params}; - -use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; - -pub struct SqliteStore { - conn: Connection, -} - -impl SqliteStore { - pub fn open(path: &Path) -> Result { - #[cfg(unix)] - let database_file = prepare_database_file(path)?; - - let store = Self { - conn: Connection::open(path)?, - }; - - #[cfg(unix)] - drop(database_file); - - 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 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 - ); - "#, - )?; - - Ok(()) - } -} - -#[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 RuntimeStore for SqliteStore { - fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError> { - let tx = self.conn.transaction()?; - - for action in actions { - match action { - Action::StoreFollower(action) => { - let follower = actor_reference_id(&action.follower); - let following = actor_reference_id(&action.following); - let inbox = actor_reference_inbox(&action.follower); - let shared_inbox = actor_reference_shared_inbox(&action.follower); - let refresh_actor = matches!(&action.follower, Reference::Object(_)); - - 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 = CASE - WHEN ?5 THEN excluded.inbox_url - ELSE followers.inbox_url - END, - shared_inbox_url = CASE - WHEN ?5 THEN excluded.shared_inbox_url - ELSE followers.shared_inbox_url - END - "#, - params![ - follower.as_str(), - following.as_str(), - inbox.map(|inbox| inbox.as_str()), - shared_inbox.map(|shared_inbox| shared_inbox.as_str()), - refresh_actor, - ], - )?; - } - Action::RemoveFollower(action) => { - tx.execute( - r#" - DELETE FROM followers - WHERE follower_actor_id = ?1 AND following_actor_id = ?2 - "#, - params![action.follower.as_str(), action.following.as_str()], - )?; - } - Action::StoreObject(action) => { - let (object_id, object_type, object_json) = encode_object(&action.object)?; - tx.execute( - r#" - INSERT INTO objects (object_id, object_type, object_json) - VALUES (?1, ?2, ?3) - ON CONFLICT(object_id) DO UPDATE SET - object_type = excluded.object_type, - object_json = excluded.object_json - "#, - params![object_id.as_str(), object_type, object_json], - )?; - } - _ => {} - } - } - - tx.commit()?; - - Ok(()) - } - - 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() - } - - fn load_object(&self, object_id: &Iri) -> Result, StoreError> { - let stored = self - .conn - .query_row( - r#" - SELECT object_type, object_json - FROM objects - WHERE object_id = ?1 - "#, - [object_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - - stored - .map(|(object_type, object_json)| decode_object(&object_type, &object_json)) - .transpose() - } - - fn insert_actor_key_pair( - &mut self, - actor_id: &Iri, - key_pair: &ActorKeyPair, - ) -> Result<(), StoreError> { - self.conn.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(()) - } - - fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, StoreError> { - let encoded_keys = self - .conn - .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 encode_object(object: &Object) -> Result<(&Iri, &'static str, String), StoreError> { - match object { - Object::Note(note) => Ok((¬e.id, "Note", serde_json::to_string(note)?)), - _ => Err(StoreError::UnsupportedObjectType), - } -} - -fn decode_object(object_type: &str, object_json: &str) -> Result { - match object_type { - "Note" => Ok(Object::Note(serde_json::from_str::(object_json)?)), - object_type => Err(StoreError::UnsupportedStoredObjectType( - object_type.to_string(), - )), - } -} - -fn actor_reference_id(reference: &Reference) -> &Iri { - match reference { - Reference::Id(id) => id, - Reference::Object(actor) => &actor.id, - } -} - -fn actor_reference_inbox(reference: &Reference) -> Option<&Iri> { - match reference { - Reference::Id(_) => None, - Reference::Object(actor) => Some(&actor.inbox), - } -} - -fn actor_reference_shared_inbox(reference: &Reference) -> Option<&Iri> { - match reference { - Reference::Id(_) => None, - Reference::Object(actor) => actor - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.as_ref()), - } -} - -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::{Action, Object, RemoveFollower, StoreFollower, StoreObject}; - - use super::*; - - const PRIVATE_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-private-key.pem"); - const PUBLIC_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-public-key.pem"); - - fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") - } - - fn store_follower_action() -> Action { - Action::StoreFollower(StoreFollower { - follower: Reference::id(iri("https://remote.example/users/bob")), - following: Reference::id(iri("https://example.com/users/alice")), - }) - } - - fn store_note_action(content: &str) -> Action { - let mut note = Note::new(iri("https://example.com/users/alice/posts/1")); - note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); - note.content = Some(content.to_string()); - - Action::StoreObject(StoreObject { - object: Object::Note(note), - }) - } - - 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 fixture") - } - - #[test] - fn open_in_memory_initializes_followers_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let table_count: i64 = store - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'followers'", - [], - |row| row.get(0), - ) - .expect("query followers table"); - - assert_eq!(table_count, 1); - - let columns: Vec = { - let mut stmt = store - .conn - .prepare("PRAGMA table_info(followers)") - .expect("prepare followers table info query"); - stmt.query_map([], |row| row.get("name")) - .expect("query followers table info") - .collect::>() - .expect("collect followers table columns") - }; - - assert!(columns.contains(&"follower_actor_id".to_string())); - assert!(columns.contains(&"following_actor_id".to_string())); - assert!(columns.contains(&"inbox_url".to_string())); - assert!(columns.contains(&"shared_inbox_url".to_string())); - - let index_count: i64 = store - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_followers_following_actor_id'", - [], - |row| row.get(0), - ) - .expect("query followers following index"); - - assert_eq!(index_count, 1); - } - - #[test] - fn open_in_memory_initializes_keys_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let columns: Vec = { - let mut stmt = store - .conn - .prepare("PRAGMA table_info(keys)") - .expect("prepare keys table info query"); - stmt.query_map([], |row| row.get("name")) - .expect("query keys table info") - .collect::>() - .expect("collect keys table columns") - }; - - assert_eq!( - columns, - vec![ - "actor_id".to_string(), - "private_key_pem".to_string(), - "public_key_pem".to_string(), - ] - ); - } - - #[test] - fn open_in_memory_initializes_objects_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let columns: Vec = { - let mut stmt = store - .conn - .prepare("PRAGMA table_info(objects)") - .expect("prepare objects table info query"); - stmt.query_map([], |row| row.get("name")) - .expect("query objects table info") - .collect::>() - .expect("collect objects table columns") - }; - - assert_eq!( - columns, - vec![ - "object_id".to_string(), - "object_type".to_string(), - "object_json".to_string(), - ] - ); - } - - #[test] - fn persist_actions_stores_and_loads_note() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let action = store_note_action("Hello from Feder."); - - store - .persist_actions(core::slice::from_ref(&action)) - .expect("persist note action"); - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - - let Action::StoreObject(expected) = action else { - panic!("expected store object action"); - }; - assert_eq!(object, expected.object); - } - - #[test] - fn persist_actions_replaces_object_with_same_id() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_note_action("Original")]) - .expect("persist original note"); - store - .persist_actions(&[store_note_action("Updated")]) - .expect("replace note"); - - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - let Object::Note(note) = object else { - panic!("expected stored note"); - }; - assert_eq!(note.content.as_deref(), Some("Updated")); - } - - #[test] - fn load_object_returns_none_for_unknown_id() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let object = store - .load_object(&iri("https://example.com/users/alice/posts/unknown")) - .expect("load unknown object"); - - assert!(object.is_none()); - } - - #[test] - fn stored_note_persists_across_store_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-object-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 store = SqliteStore::open(&path).expect("open SQLite store"); - store - .persist_actions(&[store_note_action("Persistent note")]) - .expect("persist note action"); - } - - let store = SqliteStore::open(&path).expect("reopen SQLite store"); - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load persisted note") - .expect("persisted note"); - let Object::Note(note) = object else { - panic!("expected stored note"); - }; - assert_eq!(note.content.as_deref(), Some("Persistent note")); - - drop(store); - let _ = std::fs::remove_file(path); - } - - #[cfg(unix)] - #[test] - fn open_creates_database_with_owner_only_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp_dir = std::env::temp_dir().join(format!( - "feder-database-permissions-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - std::fs::create_dir(&temp_dir).expect("create temporary directory"); - let path = temp_dir.join("store.sqlite3"); - - let store = SqliteStore::open(&path).expect("open SQLite store"); - - let mode = std::fs::metadata(&path) - .expect("read database metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - - drop(store); - std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); - } - - #[cfg(unix)] - #[test] - fn open_restricts_existing_database_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp_dir = std::env::temp_dir().join(format!( - "feder-existing-database-permissions-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - std::fs::create_dir(&temp_dir).expect("create temporary directory"); - let path = temp_dir.join("store.sqlite3"); - std::fs::write(&path, []).expect("create permissive database file"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) - .expect("make database file permissive"); - - let store = SqliteStore::open(&path).expect("open SQLite store"); - - let mode = std::fs::metadata(&path) - .expect("read database metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - - drop(store); - std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); - } - - #[test] - fn actor_key_pair_roundtrips_for_actor() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let actor_id = iri("https://example.com/users/alice"); - let expected = actor_key_pair(); - - store - .insert_actor_key_pair(&actor_id, &expected) - .expect("insert actor key pair"); - let actual = store - .load_actor_key_pair(&actor_id) - .expect("load actor key pair") - .expect("stored actor key pair"); - - assert_eq!(actual, expected); - } - - #[test] - fn actor_key_pair_persists_across_store_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-actor-key-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - let actor_id = iri("https://example.com/users/alice"); - let expected = actor_key_pair(); - - { - let mut store = SqliteStore::open(&path).expect("open SQLite store"); - store - .insert_actor_key_pair(&actor_id, &expected) - .expect("insert actor key pair"); - } - - let store = SqliteStore::open(&path).expect("reopen SQLite store"); - let actual = store - .load_actor_key_pair(&actor_id) - .expect("load actor key pair") - .expect("persisted actor key pair"); - - assert_eq!(actual, expected); - - drop(store); - let _ = std::fs::remove_file(path); - } - - #[test] - fn load_actor_key_pair_returns_none_for_unknown_actor() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let key_pair = store - .load_actor_key_pair(&iri("https://example.com/users/unknown")) - .expect("load actor key pair"); - - assert!(key_pair.is_none()); - } - - #[test] - fn load_actor_key_pair_rejects_invalid_stored_keys() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - r#" - INSERT INTO keys (actor_id, private_key_pem, public_key_pem) - VALUES (?1, ?2, ?3) - "#, - params![ - "https://example.com/users/alice", - "not a private key", - PUBLIC_KEY_PEM, - ], - ) - .expect("insert invalid actor key pair"); - - let result = store.load_actor_key_pair(&iri("https://example.com/users/alice")); - - assert!(matches!(result, Err(StoreError::ActorKey(_)))); - } - - #[test] - fn insert_actor_key_pair_refuses_to_replace_existing_key() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let actor_id = iri("https://example.com/users/alice"); - let key_pair = actor_key_pair(); - - store - .insert_actor_key_pair(&actor_id, &key_pair) - .expect("insert actor key pair"); - let result = store.insert_actor_key_pair(&actor_id, &key_pair); - - assert!(matches!(result, Err(StoreError::Sqlite(_)))); - } - - #[test] - fn persist_actions_stores_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - 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 persist_actions_removes_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - store - .persist_actions(&[Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - .expect("persist follower removal action"); - - let follower_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) - .expect("query follower count"); - assert_eq!(follower_count, 0); - } - - #[test] - fn persist_actions_stores_embedded_follower_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let action = Action::StoreFollower(StoreFollower { - follower: Reference::object(actor("https://remote.example/users/bob")), - following: Reference::id(iri("https://example.com/users/alice")), - }); - - store - .persist_actions(&[action]) - .expect("persist follower action"); - - let inbox: Option = store - .conn - .query_row("SELECT inbox_url FROM followers", [], |row| row.get(0)) - .expect("query stored follower inbox"); - - assert_eq!( - inbox.as_deref(), - Some("https://remote.example/users/bob/inbox") - ); - } - - #[test] - fn persist_actions_stores_embedded_follower_shared_inbox() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - let action = Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - }); - - store - .persist_actions(&[action]) - .expect("persist follower action"); - - let shared_inbox: Option = store - .conn - .query_row("SELECT shared_inbox_url FROM followers", [], |row| { - row.get(0) - }) - .expect("query stored follower shared inbox"); - - assert_eq!( - shared_inbox.as_deref(), - Some("https://remote.example/inbox") - ); - } - - #[test] - fn persist_actions_ignores_duplicate_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let action = store_follower_action(); - - store - .persist_actions(core::slice::from_ref(&action)) - .expect("persist follower action first time"); - store - .persist_actions(&[action]) - .expect("persist follower action 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 persist_actions_updates_follower_inbox_from_repeated_follow() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist ID-only follower action"); - let mut follower = actor("https://remote.example/users/bob"); - follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist repeated follower action"); - - 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 persist_actions_clears_removed_shared_inbox_from_embedded_actor() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist follower with shared inbox"); - - let mut updated_follower = actor("https://remote.example/users/bob"); - updated_follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(updated_follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist follower without shared inbox"); - - 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 persist_actions_preserves_inboxes_from_id_only_repeated_follow() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist embedded follower"); - store - .persist_actions(&[store_follower_action()]) - .expect("persist ID-only repeated follower"); - - 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_followers_returns_stored_followers() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - 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"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - let action = Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - }); - - store - .persist_actions(&[action]) - .expect("persist follower action"); - - 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"); - let bob_follows_alice = Action::StoreFollower(StoreFollower { - follower: Reference::id(iri("https://remote.example/users/bob")), - following: Reference::id(iri("https://example.com/users/alice")), - }); - let carol_follows_eve = Action::StoreFollower(StoreFollower { - follower: Reference::id(iri("https://remote.example/users/carol")), - following: Reference::id(iri("https://example.com/users/eve")), - }); - - store - .persist_actions(&[bob_follows_alice, carol_follows_eve]) - .expect("persist follower actions"); - - 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"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - let follower_with_inbox = Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - }); - let follower_without_inbox = Action::StoreFollower(StoreFollower { - follower: Reference::id(iri("https://remote.example/users/carol")), - following: Reference::id(iri("https://example.com/users/alice")), - }); - - store - .persist_actions(&[follower_with_inbox, follower_without_inbox]) - .expect("persist follower actions"); - - 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"); - let bob_follows_alice = Action::StoreFollower(StoreFollower { - follower: Reference::object(actor("https://remote.example/users/bob")), - following: Reference::id(iri("https://example.com/users/alice")), - }); - let carol_follows_eve = Action::StoreFollower(StoreFollower { - follower: Reference::object(actor("https://remote.example/users/carol")), - following: Reference::id(iri("https://example.com/users/eve")), - }); - - store - .persist_actions(&[bob_follows_alice, carol_follows_eve]) - .expect("persist follower actions"); - - 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/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs deleted file mode 100644 index e2ed22d..0000000 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ /dev/null @@ -1,130 +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::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_config, test_router}; - -#[tokio::test] -async fn returns_local_actor() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .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(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!( - json["@context"], - serde_json::json!([ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ]) - ); - 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["followers"], - "http://127.0.0.1:3000/users/alice/followers" - ); - assert_eq!(json["preferredUsername"], "alice"); - assert_eq!(json["name"], "alice"); - assert_eq!( - json["publicKey"], - serde_json::json!({ - "id": "http://127.0.0.1:3000/users/alice#main-key", - "type": "CryptographicKey", - "owner": "http://127.0.0.1:3000/users/alice", - "publicKeyPem": include_str!("../fixtures/rsa-public-key.pem"), - }) - ); -} - -#[tokio::test] -async fn rejects_actor_request_when_html_is_preferred() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice") - .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") - .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_actor_request_without_activitypub_accept() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice") - .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 app = test_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/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs deleted file mode 100644 index 6fd8552..0000000 --- a/crates/feder-runtime-server/tests/cases/app.rs +++ /dev/null @@ -1,82 +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::Body, - http::{Request, StatusCode}, -}; -use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; -use feder_vocab::Reference; -use tower::ServiceExt; - -use crate::common::{temporary_database_path, test_config, test_router}; - -#[tokio::test] -async fn returns_health_check() { - let app = test_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); -} - -#[test] -fn startup_generates_then_reuses_persisted_actor_key_pair() { - let path = temporary_database_path("feder-startup-key-test"); - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let first = AppState::from_config(config).expect("build first app state"); - let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); - let Reference::Object(published_key) = first - .local_actor - .public_key - .as_ref() - .expect("actor publishes public key") - else { - panic!("actor public key should be embedded"); - }; - assert_eq!( - published_key.id.as_str(), - "http://127.0.0.1:3000/users/alice#main-key" - ); - assert_eq!(published_key.owner, first.local_actor.id); - assert_eq!(published_key.public_key_pem, expected_public_key); - let stored = first - .store - .lock() - .expect("lock store") - .load_actor_key_pair(&first.local_actor.id) - .expect("load actor key pair") - .expect("stored actor key pair"); - assert_eq!(stored, *first.actor_key_pair); - drop(first); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let second = AppState::from_config(config).expect("reopen app state"); - - assert_eq!(second.actor_key_pair.public_key_pem(), expected_public_key); - - drop(second); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs deleted file mode 100644 index bfeb6cb..0000000 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ /dev/null @@ -1,169 +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::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use feder_core::{Action, RemoveFollower, StoreFollower}; -use feder_runtime_server::{app::router_with_state, storage::RuntimeStore}; -use feder_vocab::{Iri, Reference}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_app_state, test_config, test_router}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -async fn get_followers(app: axum::Router, username: &str) -> axum::response::Response { - app.oneshot( - Request::builder() - .uri(format!("/users/{username}/followers")) - .header(header::ACCEPT, "application/activity+json") - .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 store_follower(follower: &str) -> Action { - Action::StoreFollower(StoreFollower { - follower: Reference::id(iri(follower)), - following: Reference::id(iri("http://127.0.0.1:3000/users/alice")), - }) -} - -#[tokio::test] -async fn returns_empty_followers_collection_with_activitypub_headers() { - let response = get_followers(test_router(test_config()).expect("build router"), "alice").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["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "OrderedCollection"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/followers"); - assert_eq!(json["totalItems"], 0); - assert_eq!(json["orderedItems"], serde_json::json!([])); -} - -#[tokio::test] -async fn returns_stored_followers_and_count() { - let state = test_app_state(test_config()).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[ - store_follower("https://remote.example/users/carol"), - store_follower("https://remote.example/users/bob"), - ]) - .expect("persist followers"); - - let response = get_followers(router_with_state(state), "alice").await; - assert_eq!(response.status(), StatusCode::OK); - - 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 state = test_app_state(test_config()).expect("build app state"); - { - let mut store = state.store.lock().expect("store lock"); - store - .persist_actions(&[store_follower("https://remote.example/users/bob")]) - .expect("persist follower"); - store - .persist_actions(&[Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: state.local_actor.id.clone(), - })]) - .expect("remove follower"); - } - - let response = get_followers(router_with_state(state), "alice").await; - assert_eq!(response.status(), StatusCode::OK); - - let json = response_json(response).await; - assert_eq!(json["totalItems"], 0); - assert_eq!(json["orderedItems"], serde_json::json!([])); -} - -#[tokio::test] -async fn rejects_unknown_username() { - let response = - get_followers(test_router(test_config()).expect("build router"), "unknown").await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn rejects_followers_request_when_html_is_preferred() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice/followers") - .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") - .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_followers_request_without_activitypub_accept() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice/followers") - .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"); -} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs deleted file mode 100644 index 4192167..0000000 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ /dev/null @@ -1,877 +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, Router, - body::{Body, Bytes}, - http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, - routing::{get, post}, -}; -use feder_core::http_signatures::{create_sha256_digest_header, sign_draft_cavage}; -use feder_runtime_server::{ - app::router_with_state, - config::{InboxAuthPolicy, StorageConfig}, - storage::RuntimeStore, -}; -use serde_json::json; -use tower::ServiceExt; - -use crate::common::{ - RecordedRequest, fixture_actor_key_pair, spawn_inbox_server, temporary_database_path, - test_app_state, test_config, test_router, -}; - -fn follow_body() -> Vec { - follow_body_for_inbox("https://remote.example/users/bob/inbox") -} - -fn follow_body_for_inbox(inbox: &str) -> Vec { - 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": inbox, - "outbox": "https://remote.example/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow") -} - -fn id_only_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": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize ID-only follow") -} - -fn undo_follow_body(actor_id: &str, follow_actor_id: &str) -> Vec { - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Undo", - "id": format!("{actor_id}/undo/1"), - "actor": actor_id, - "object": { - "type": "Follow", - "id": format!("{actor_id}/follows/1"), - "actor": follow_actor_id, - "object": "http://127.0.0.1:3000/users/alice" - } - })) - .expect("serialize Undo Follow") -} - -async fn spawn_actor_server() -> ( - String, - tokio::sync::mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - let (actor_id, _key_id, receiver, task) = spawn_actor_server_inner(false).await; - (actor_id, receiver, task) -} - -async fn spawn_actor_server_with_separate_key() -> ( - String, - String, - tokio::sync::mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - spawn_actor_server_inner(true).await -} - -async fn spawn_actor_server_inner( - separate_key: bool, -) -> ( - String, - String, - tokio::sync::mpsc::Receiver, - 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 = format!("http://{address}/users/bob"); - let key_id = if separate_key { - format!("http://{address}/keys/1") - } else { - format!("{actor_id}#main-key") - }; - let inbox = format!("http://{address}/inbox"); - let public_key = json!({ - "id": key_id, - "owner": actor_id, - "publicKeyPem": fixture_actor_key_pair() - .expect("load actor key fixture") - .public_key_pem() - }); - let actor = json!({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - { "toot": "http://joinmastodon.org/ns#" } - ], - "type": "Person", - "id": actor_id, - "inbox": inbox, - "outbox": format!("http://{address}/users/bob/outbox"), - "preferredUsername": "bob", - "endpoints": { "sharedInbox": inbox }, - "publicKey": if separate_key { json!(key_id) } else { public_key.clone() } - }); - let (sender, receiver) = tokio::sync::mpsc::channel(1); - let app = Router::new() - .route( - "/users/bob", - get(move || { - let actor = actor.clone(); - async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } - }), - ) - .route( - "/keys/1", - get(move || { - let public_key = public_key.clone(); - async move { - ( - [(CONTENT_TYPE, "application/activity+json")], - Json(public_key), - ) - } - }), - ) - .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 actor endpoint"); - }); - - (actor_id, key_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 request"), - ) - .await - .expect("response") -} - -async fn post_signed_inbox( - app: Router, - uri: &str, - key_id: &str, - signed_body: &[u8], - delivered_body: impl Into, -) -> axum::response::Response { - post_signed_inbox_with_host( - app, - uri, - key_id, - signed_body, - delivered_body, - "127.0.0.1:3000", - ) - .await -} - -async fn post_signed_inbox_with_host( - app: Router, - uri: &str, - key_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( - &fixture_actor_key_pair().expect("load actor key fixture"), - key_id, - "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 request"), - ) - .await - .expect("response") -} - -#[tokio::test] -async fn valid_follow_reaches_core() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - { - let core = state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); - assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" - ); - assert_eq!( - core.state().followers()[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" - ); - } - let followers = state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers"); - assert_eq!(followers[0].inbox.as_ref().unwrap().as_str(), inbox); - - let request = requests.recv().await.expect("receive Accept request"); - assert_eq!( - request.headers.get(CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Accept"); - assert_eq!(activity["actor"], "http://127.0.0.1:3000/users/alice"); - assert_eq!( - activity["object"]["id"], - "https://remote.example/activities/follow-1" - ); - inbox_server.abort(); -} - -#[tokio::test] -async fn resolves_id_only_follower_and_sends_accept() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - id_only_follow_body(&actor_id), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - let followers = state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers"); - assert_eq!(followers.len(), 1); - assert_eq!(followers[0].follower.as_str(), actor_id); - assert_eq!( - followers[0] - .inbox - .as_ref() - .expect("resolved inbox") - .as_str(), - format!("{}/inbox", actor_id.trim_end_matches("/users/bob")) - ); - - let request = requests.recv().await.expect("receive Accept request"); - assert!(request.headers.contains_key("signature")); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Accept"); - assert_eq!(activity["object"]["actor"]["id"], actor_id); - actor_server.abort(); -} - -#[tokio::test] -async fn verifies_signed_id_only_follow() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive Accept request"); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_host_for_another_authority() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox_with_host( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - "other.example", - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn verifies_signed_follow_with_independent_key_id() { - let (actor_id, key_id, mut requests, actor_server) = - spawn_actor_server_with_separate_key().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive Accept request"); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_undo_follow_removes_persisted_follower() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let key_id = format!("{actor_id}#main-key"); - let follow_body = id_only_follow_body(&actor_id); - let follow_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &follow_body, - follow_body.clone(), - ) - .await; - assert_eq!(follow_response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - - let undo_body = undo_follow_body(&actor_id, &actor_id); - let undo_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &undo_body, - undo_body.clone(), - ) - .await; - - assert_eq!(undo_response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - assert!( - state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers") - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_undo_follow_rejects_actor_that_does_not_own_follow() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let key_id = format!("{actor_id}#main-key"); - let follow_body = id_only_follow_body(&actor_id); - let follow_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &follow_body, - follow_body.clone(), - ) - .await; - assert_eq!(follow_response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - - let undo_body = undo_follow_body(&actor_id, "https://remote.example/users/mallory"); - let undo_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &undo_body, - undo_body.clone(), - ) - .await; - - assert_eq!(undo_response.status(), StatusCode::UNAUTHORIZED); - assert_eq!( - state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers") - .len(), - 1 - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_tampered_body() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let signed_body = id_only_follow_body(&actor_id); - let delivered_body = id_only_follow_body("https://attacker.example/users/mallory"); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &signed_body, - delivered_body, - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_actor_different_from_key_owner() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body("https://attacker.example/users/mallory"); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn send_failure_returns_bad_gateway_after_core_handling() { - let (inbox, mut requests, inbox_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive failed request"); - inbox_server.abort(); -} - -#[tokio::test] -async fn require_signed_rejects_unsigned_follow_before_core() { - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_unknown_inbox_actor() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/bob/inbox", - "application/activity+json", - follow_body(), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_unsupported_content_type() { - for content_type in [ - "application/json", - "application/activity+jsonp", - "not a media type", - ] { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - content_type, - follow_body(), - ) - .await; - - assert_eq!( - response.status(), - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "Content-Type: {content_type}" - ); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } -} - -#[tokio::test] -async fn accepts_case_insensitive_content_type_with_parameters() { - for content_type in [ - "Application/Activity+JSON; Charset=UTF-8", - "Application/LD+JSON; Profile=\"https://www.w3.org/ns/activitystreams\"", - ] { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - content_type, - "{not json", - ) - .await; - - assert_eq!( - response.status(), - StatusCode::BAD_REQUEST, - "Content-Type: {content_type}" - ); - } -} - -#[tokio::test] -async fn rejects_malformed_json() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - "{not json", - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn ignores_unsupported_activity_without_mutating_core() { - let state = test_app_state(test_config()).expect("build app state"); - let body = 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( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - body, - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn ignores_undo_of_unsupported_activity_without_mutating_core() { - let state = test_app_state(test_config()).expect("build app state"); - let body = serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Undo", - "id": "https://remote.example/activities/undo-like-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Like", - "id": "https://remote.example/activities/like-1", - "actor": "https://remote.example/users/bob", - "object": "http://127.0.0.1:3000/users/alice/notes/1" - } - })) - .expect("serialize Undo Like"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - body, - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_oversized_inbox_body() { - let response = post_inbox( - test_router(test_config()).expect("build router"), - "/users/alice/inbox", - "application/activity+json", - vec![b' '; 1_048_577], - ) - .await; - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); -} - -#[tokio::test] -async fn sqlite_storage_persists_followers_across_app_state_reopen() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let path = temporary_database_path("feder-runtime-server-test"); - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - inbox_server.abort(); - drop(state); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("reopen app state"); - let followers = 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" - ); - - drop(state); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs deleted file mode 100644 index 55729d3..0000000 --- a/crates/feder-runtime-server/tests/cases/object.rs +++ /dev/null @@ -1,237 +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::{ - Router, - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use feder_core::{Action, Object, PUBLIC_COLLECTION, StoreObject}; -use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; -use feder_vocab::{Iri, Note, Reference, References}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -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.published = Some("2026-07-21T00:00:00Z".to_string()); - note.url = Some(note.id.clone()); - note -} - -fn router_with_stored_note(note: Note) -> Router { - let state = test_app_state(test_config()).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(note), - })]) - .expect("persist note"); - router_with_state(state) -} - -fn router_with_note() -> Router { - router_with_stored_note(stored_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_stored_note_with_activitypub_headers() { - let response = get_object( - router_with_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["id"], "http://127.0.0.1:3000/users/alice/posts/1"); - assert_eq!(json["content"], "Hello from Feder."); - assert_eq!(json["mediaType"], "text/html"); -} - -#[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_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn returns_not_found_for_direct_note() { - let mut note = stored_note(); - note.to = References::one(iri("https://remote.example/users/bob")); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_not_found_for_followers_only_note() { - let mut note = stored_note(); - note.to = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_not_found_for_note_without_audience() { - let mut note = stored_note(); - note.to = References::new(); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn rejects_note_request_when_html_is_preferred() { - let response = get_object( - router_with_note(), - "/users/alice/posts/1", - Some("text/html, application/activity+json;q=0.8"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn rejects_note_request_without_activitypub_accept() { - let response = get_object(router_with_note(), "/users/alice/posts/1", None).await; - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn returns_not_found_for_unknown_note() { - let response = get_object( - router_with_note(), - "/users/alice/posts/unknown", - Some("text/html"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_note_after_store_reopen() { - let path = temporary_database_path("feder-object-route-test"); - { - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(stored_note()), - })]) - .expect("persist note"); - } - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let response = get_object( - test_router(config).expect("reopen router"), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let _ = std::fs::remove_file(path); -} - -#[tokio::test] -async fn returns_not_found_for_unknown_username() { - let response = get_object( - router_with_note(), - "/users/bob/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs deleted file mode 100644 index ca25836..0000000 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ /dev/null @@ -1,308 +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, Router, - http::{StatusCode, header}, - routing::get, -}; -use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; -use feder_runtime_server::{ - Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, -}; -use feder_vocab::{Actor, Endpoints, Iri, Reference}; -use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; - -use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -fn create_note_input() -> UserCreateNote { - UserCreateNote { - note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), - create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), - actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), - to: feder_vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), - cc: feder_vocab::References::one(iri("http://127.0.0.1:3000/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("http://127.0.0.1:3000/@alice/1")), - } -} - -fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { - store_follower_with_shared_inbox(state, remote_actor_id, inbox, None); -} - -fn store_follower_with_shared_inbox( - state: &feder_runtime_server::AppState, - remote_actor_id: &str, - inbox: &str, - shared_inbox: Option<&str>, -) { - let remote_actor_id = iri(remote_actor_id); - let mut remote_actor = Actor::person( - remote_actor_id.clone(), - iri(inbox), - iri(&format!("{remote_actor_id}/outbox")), - ); - remote_actor.endpoints = shared_inbox.map(|shared_inbox| Endpoints { - shared_inbox: Some(iri(shared_inbox)), - }); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(remote_actor), - following: Reference::id(state.local_actor.id.clone()), - })]) - .expect("persist follower"); -} - -async fn spawn_actor_server(inbox: &str) -> (Iri, Iri, 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 missing_actor_id = iri(&format!("http://{address}/users/missing")); - let actor = Actor::person( - actor_id.clone(), - iri(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, "application/activity+json")], - Json(actor), - ) - } - }), - ); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("serve actor endpoint"); - }); - - (actor_id, missing_actor_id, task) -} - -#[tokio::test] -async fn create_note_persists_and_delivers_the_core_actions() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - - let result = state - .create_note(create_note_input()) - .await - .expect("create note"); - - assert_eq!(result.actions.len(), 2); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - assert!(matches!( - &result.actions[1], - Action::SendActivity(send) - if matches!(&send.recipients, Recipients::Followers(_)) - )); - - let stored = state - .store - .lock() - .expect("store lock") - .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - let Object::Note(note) = stored else { - panic!("expected stored Note"); - }; - assert_eq!(note.content.as_deref(), Some("Hello from Feder.")); - - let request = requests.recv().await.expect("receive Create request"); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid Create activity"); - assert_eq!(activity["type"], "Create"); - assert_eq!( - activity["to"], - "https://www.w3.org/ns/activitystreams#Public" - ); - assert_eq!( - activity["cc"], - "http://127.0.0.1:3000/users/alice/followers" - ); - assert_eq!(activity["object"]["id"], note.id.as_str()); - assert_eq!(activity["object"]["to"], activity["to"]); - assert_eq!(activity["object"]["cc"], activity["cc"]); - assert_eq!(activity["object"]["mediaType"], "text/html"); - assert_eq!(activity["object"]["url"], "http://127.0.0.1:3000/@alice/1"); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_to_each_persisted_follower() { - let (bob_inbox, mut bob_requests, bob_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (carol_inbox, mut carol_requests, carol_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &bob_inbox); - store_follower(&state, "https://another.example/users/carol", &carol_inbox); - - state - .create_note(create_note_input()) - .await - .expect("create note"); - - bob_requests.recv().await.expect("receive Bob delivery"); - carol_requests.recv().await.expect("receive Carol delivery"); - bob_server.abort(); - carol_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_once_to_shared_inbox_when_direct_actor_is_also_a_follower() { - let (personal_inbox, mut personal_requests, personal_inbox_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let (shared_inbox, mut shared_requests, shared_inbox_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&personal_inbox).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower_with_shared_inbox( - &state, - actor_id.as_str(), - &personal_inbox, - Some(&shared_inbox), - ); - let mut input = create_note_input(); - input.to = feder_vocab::References::one(actor_id); - input.cc = feder_vocab::References::one( - state - .local_actor - .followers - .clone() - .expect("local actor has followers collection"), - ); - - state.create_note(input).await.expect("create note"); - - shared_requests - .recv() - .await - .expect("receive shared inbox delivery"); - assert!(matches!( - shared_requests.try_recv(), - Err(TryRecvError::Empty) - )); - assert!(matches!( - personal_requests.try_recv(), - Err(TryRecvError::Empty) - )); - actor_server.abort(); - shared_inbox_server.abort(); - personal_inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; - let state = test_app_state(test_config()).expect("build app state"); - let mut input = create_note_input(); - input.to = feder_vocab::References::one(missing_actor_id); - input.cc = feder_vocab::References::one(actor_id); - - let result = state.create_note(input).await; - - assert!(matches!( - result, - Err(Error::ActorResolver( - ActorResolveError::UnsuccessfulStatus { .. } - )) - )); - requests - .recv() - .await - .expect("receive delivery for resolvable actor"); - actor_server.abort(); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_keeps_the_persisted_object_when_delivery_fails() { - let (inbox, mut requests, inbox_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - - let result = state.create_note(create_note_input()).await; - - assert!(matches!( - result, - Err(Error::ActivitySender(SendError::UnsuccessfulStatus { .. })) - )); - requests.recv().await.expect("receive Create request"); - assert!( - state - .store - .lock() - .expect("store lock") - .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) - .expect("load note") - .is_some() - ); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_resolves_persisted_followers_after_restart() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let path = temporary_database_path("feder-create-note-recipients-test"); - let mut first_config = test_config(); - first_config.storage = StorageConfig::Sqlite { path: path.clone() }; - { - let state = test_app_state(first_config).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - } - - let mut second_config = test_config(); - second_config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(second_config).expect("reopen app state"); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - - state - .create_note(create_note_input()) - .await - .expect("create note after restart"); - - requests.recv().await.expect("receive Create request"); - inbox_server.abort(); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs deleted file mode 100644 index 93bf867..0000000 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ /dev/null @@ -1,234 +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::http::StatusCode; -use feder_core::{ - Activity, Recipients, SendActivity, - http_signatures::{ActorKeyPair, sign_draft_cavage}, -}; -use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; -use feder_vocab::{Create, Follow, Note, Reference}; - -use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; - -fn create_note_send_action(inbox: &str) -> SendActivity { - let actor_id = "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"); - let note = Note::new( - "https://local.example/notes/1" - .parse() - .expect("valid note IRI"), - ); - let create = Create::new( - "https://local.example/activities/create-1" - .parse() - .expect("valid activity IRI"), - Reference::id(actor_id), - Reference::object(note), - ); - - SendActivity { - activity: Activity::CreateNote(create), - recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), - } -} - -fn follow_send_action(inbox: &str) -> SendActivity { - let follow = Follow::new( - "https://local.example/activities/follow-1" - .parse() - .expect("valid activity IRI"), - Reference::id( - "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"), - ), - Reference::id( - "https://remote.example/users/bob" - .parse() - .expect("valid actor IRI"), - ), - ); - - SendActivity { - activity: Activity::Follow(follow), - recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), - } -} - -#[tokio::test] -async fn sends_create_note_action() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let inbox = format!("{inbox}?shared=true"); - let actions = [create_note_send_action(&inbox)]; - - test_activity_sender() - .send_actions(&actions) - .await - .expect("send Create activity"); - - let request = requests.recv().await.expect("receive Create request"); - assert_eq!(request.uri, "/inbox?shared=true"); - assert_eq!( - request.headers["host"], - inbox - .strip_prefix("http://") - .and_then(|value| value.split_once('/').map(|(authority, _)| authority)) - .expect("inbox authority") - ); - assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); - assert_eq!( - request.headers["digest"], - feder_core::http_signatures::create_sha256_digest_header(&request.body) - ); - let signature = request.headers["signature"].to_str().unwrap(); - 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()), - ]; - let key_pair = ActorKeyPair::from_pem( - include_str!("../fixtures/rsa-private-key.pem").to_string(), - include_str!("../fixtures/rsa-public-key.pem").to_string(), - ) - .expect("load actor key pair fixture"); - let expected_signature = sign_draft_cavage( - &key_pair, - "https://local.example/users/alice#main-key", - "POST", - "/inbox?shared=true", - &headers, - ) - .expect("sign captured request"); - assert_eq!(signature, expected_signature); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Create"); - assert_eq!(activity["actor"], "https://local.example/users/alice"); - assert_eq!(activity["object"]["type"], "Note"); - inbox_server.abort(); -} - -#[tokio::test] -async fn sends_follow_action() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - - test_activity_sender() - .send_actions(&[follow_send_action(&inbox)]) - .await - .expect("send Follow activity"); - - let request = requests.recv().await.expect("receive Follow request"); - assert_eq!(request.uri, "/inbox"); - assert_eq!(request.headers["content-type"], "application/activity+json"); - assert!(request.headers.contains_key("signature")); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Follow"); - assert_eq!(activity["actor"], "https://local.example/users/alice"); - assert_eq!(activity["object"], "https://remote.example/users/bob"); - inbox_server.abort(); -} - -#[tokio::test] -async fn rejects_unresolved_follower_recipients() { - let mut action = create_note_send_action("https://remote.example/inbox"); - action.recipients = Recipients::Followers( - "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"), - ); - - let result = test_activity_sender().send_actions(&[action]).await; - - assert!(matches!(result, Err(SendError::UnresolvedRecipients))); -} - -#[tokio::test] -async fn attempts_later_sends_after_failure() { - let (failed_inbox, mut failed_requests, failed_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let (successful_inbox, mut successful_requests, successful_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let actions = [ - create_note_send_action(&failed_inbox), - create_note_send_action(&successful_inbox), - ]; - - let result = test_activity_sender().send_actions(&actions).await; - - assert!(result.is_err()); - failed_requests - .recv() - .await - .expect("receive failed request"); - successful_requests - .recv() - .await - .expect("receive later request"); - failed_server.abort(); - successful_server.abort(); -} - -#[tokio::test] -async fn blocks_literal_private_inbox_address() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let actions = [create_note_send_action(&inbox)]; - - let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) - .send_actions(&actions) - .await; - - assert!(matches!( - result, - Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() - )); - assert!(requests.try_recv().is_err()); - inbox_server.abort(); -} - -#[tokio::test] -async fn blocks_hostname_resolving_to_private_address() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let inbox = inbox.replacen("127.0.0.1", "localhost", 1); - let actions = [create_note_send_action(&inbox)]; - - let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) - .send_actions(&actions) - .await; - - assert!(matches!(result, Err(SendError::Request(_)))); - assert!(requests.try_recv().is_err()); - inbox_server.abort(); -} - -#[tokio::test] -async fn blocks_special_use_ipv6_inbox_addresses() { - let sender = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly); - - for address in ["100:0:0:1::1", "2001:2::1", "5f00::1"] { - let inbox = format!("http://[{address}]/inbox"); - let actions = [create_note_send_action(&inbox)]; - - let result = sender.send_actions(&actions).await; - - assert!(matches!(result, Err(SendError::PrivateInboxAddress { .. }))); - } -} diff --git a/crates/feder-runtime-server/tests/cases/webfinger.rs b/crates/feder-runtime-server/tests/cases/webfinger.rs deleted file mode 100644 index 3b6c963..0000000 --- a/crates/feder-runtime-server/tests/cases/webfinger.rs +++ /dev/null @@ -1,94 +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::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_config, test_router}; - -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 = test_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 = test_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 = test_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-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs deleted file mode 100644 index 399fc04..0000000 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ /dev/null @@ -1,179 +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 axum::{ - Router, - body::Bytes, - http::{HeaderMap, StatusCode, Uri}, - routing::post, -}; -use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; -use feder_runtime_server::{ - Error, - actor::ActorResolver, - app::{AppState, router_with_state}, - config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, - send::ActivitySender, - storage::{RuntimeStore, SqliteStore}, -}; -use feder_vocab::{Actor, CryptographicKey, Reference}; -use iri_string::types::IriFragmentStr; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub struct RecordedRequest { - pub headers: HeaderMap, - pub uri: Uri, - pub body: Bytes, -} - -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) -} - -pub 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, - outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, - storage: StorageConfig::InMemory, - } -} - -pub fn test_app_state(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()); - actor.followers = Some( - format!("{}/followers", actor.id.as_str().trim_end_matches('/')) - .parse() - .expect("valid followers IRI"), - ); - - let mut store = match &config.storage { - StorageConfig::InMemory => SqliteStore::open_in_memory()?, - StorageConfig::Sqlite { path } => SqliteStore::open(path)?, - }; - let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { - Some(key_pair) => key_pair, - None => { - let key_pair = fixture_actor_key_pair()?; - store.insert_actor_key_pair(&actor.id, &key_pair)?; - key_pair - } - }; - let mut key_id = actor.id.clone(); - key_id.set_fragment(Some( - IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), - )); - actor.set_public_key(Reference::object(CryptographicKey::new( - key_id.clone(), - actor.id.clone(), - actor_key_pair.public_key_pem().to_string(), - ))); - let core = FederCore::new(FederConfig::new(actor.clone())); - let actor_key_pair = Arc::new(actor_key_pair); - let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; - let activity_sender = ActivitySender::new( - actor_key_pair.clone(), - key_id.to_string(), - config.outbound_address_policy, - )?; - - Ok(AppState { - core: Arc::new(Mutex::new(core)), - store: Arc::new(Mutex::new(store)), - actor_key_pair, - actor_resolver, - activity_sender, - local_actor: actor, - username: config.username, - handle_host: config.handle_host, - inbox_auth_policy: config.inbox_auth_policy, - }) -} - -pub fn test_router(config: RuntimeConfig) -> Result { - Ok(router_with_state(test_app_state(config)?)) -} - -pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "{prefix}-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )) -} - -pub fn fixture_actor_key_pair() -> Result { - ActorKeyPair::from_pem( - include_str!("../fixtures/rsa-private-key.pem").to_string(), - include_str!("../fixtures/rsa-public-key.pem").to_string(), - ) -} - -pub fn test_activity_sender() -> ActivitySender { - test_activity_sender_with_policy(OutboundAddressPolicy::AllowPrivateAddress) -} - -pub fn test_activity_sender_with_policy(policy: OutboundAddressPolicy) -> ActivitySender { - ActivitySender::new( - Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), - "https://local.example/users/alice#main-key".to_string(), - policy, - ) - .expect("build activity sender") -} diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-server/Cargo.toml similarity index 71% rename from crates/feder-runtime-server/Cargo.toml rename to crates/feder-server/Cargo.toml index 06a4aaf..bd880bf 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-server/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "feder-runtime-server" -description = "Runnable server runtime for Feder on standard operating systems." +name = "feder-server" +description = "ActivityPub server runtime for Feder on standard operating systems." version.workspace = true edition.workspace = true authors.workspace = true @@ -9,25 +9,28 @@ homepage.workspace = true repository.workspace = true [dependencies] -feder-core = { workspace = true, features = ["http-signatures"] } -feder-vocab.workspace = true -iri-string.workspace = true axum = "0.8" +feder-vocab.workspace = true httpdate = "1" -ipnet = "2.11.0" -mime = "0.3" -serde.workspace = true -thiserror = "2" -serde_json.workspace = true +mime = "0.3.17" percent-encoding = "2.3.2" rand_core.workspace = true -reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } +feder-core = { workspace = true, features = ["http-signatures"] } +reqwest = { + version = "0.13.1", + default-features = false, + features = ["rustls"], +} rusqlite = { version = "0.40.1", features = ["bundled"] } -tokio = { version = "1", features = ["net"] } +serde.workspace = true +serde_json.workspace = true +thiserror = "2.0.19" url = "2" +ipnet = "2.11.0" +tokio = { version = "1", features = ["net"] } [dev-dependencies] -serde_json.workspace = true +tempfile = "3" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } tower.workspace = true diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-server/src/actor.rs similarity index 90% rename from crates/feder-runtime-server/src/actor.rs rename to crates/feder-server/src/actor.rs index c74fa56..e84a677 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-server/src/actor.rs @@ -13,12 +13,15 @@ // 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, @@ -26,35 +29,53 @@ use reqwest::{ }; use serde::Deserialize; -use crate::{app::AppState, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; +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\""; -pub async fn actor( - State(app_state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); +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 local_actor = app_state.local_actor.clone(); + + 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(local_actor), + Json(actor), ) .into_response()) } -/// Resolves remote ActivityPub actors for runtime protocol handling. #[derive(Clone, Debug)] pub struct ActorResolver { client: Client, diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-server/src/config.rs similarity index 67% rename from crates/feder-runtime-server/tests/runtime.rs rename to crates/feder-server/src/config.rs index 7e1d253..d645672 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-server/src/config.rs @@ -13,21 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -mod common; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundAddressPolicy { + /// Allows only publicly routable destination addresses. + #[default] + PublicOnly, -#[path = "cases/actor.rs"] -mod actor; -#[path = "cases/app.rs"] -mod app; -#[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; + /// 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..fa401ee --- /dev/null +++ b/crates/feder-server/src/follow.rs @@ -0,0 +1,89 @@ +// 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()))?; + self.sender() + .send_activity( + &local_actor, + &key_pair, + &outcome.activity, + &remote_actor.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-runtime-server/src/followers.rs b/crates/feder-server/src/followers.rs similarity index 70% rename from crates/feder-runtime-server/src/followers.rs rename to crates/feder-server/src/followers.rs index b42ac7e..cc38b5c 100644 --- a/crates/feder-runtime-server/src/followers.rs +++ b/crates/feder-server/src/followers.rs @@ -13,47 +13,48 @@ // 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::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; +use crate::{FederServer, negotiation::accepts_activitypub}; -/// Return the local actor's followers as a one-shot ordered collection. -pub async fn followers( - State(app_state): State, - Path(username): Path, +pub async fn followers( + State(server): State>>, + Path(identifier): Path, headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } +) -> 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 = app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .list_followers(&app_state.local_actor.id) + 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 ordered_items = followers - .into_iter() - .map(|follower| follower.follower) - .collect(); - let collection_id = app_state - .local_actor + let collection_id = actor .followers .clone() .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - let collection = OrderedCollection::new(collection_id, total_items, ordered_items); + let collection = OrderedCollection::new(collection_id, total_items, followers); Ok(( [ diff --git a/crates/feder-server/src/inbox.rs b/crates/feder-server/src/inbox.rs new file mode 100644 index 0000000..5698752 --- /dev/null +++ b/crates/feder-server/src/inbox.rs @@ -0,0 +1,643 @@ +// 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") => { + if value + .get("object") + .filter(|object| object.is_object()) + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + .is_some_and(|kind| kind != "Follow") + { + return Ok(StatusCode::ACCEPTED.into_response()); + } + 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") => { + if value + .get("object") + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + != Some("Follow") + { + return Ok(StatusCode::ACCEPTED.into_response()); + } + 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 !matches!( + signature.algorithm.as_deref(), + None | Some("rsa-sha256" | "hs2019") + ) || 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: Option, + 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") + .map(|algorithm| algorithm.to_ascii_lowercase()); + 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, + 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..51276fc --- /dev/null +++ b/crates/feder-server/src/lib.rs @@ -0,0 +1,170 @@ +// 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, + }) + } + + /// Constructs a server with an explicit outbound-address policy. + /// + /// Allowing private addresses disables Feder's SSRF protection and must + /// only be used in trusted development or test environments. Production + /// servers should use [`FederServer::new`], which permits public addresses + /// only. + 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-runtime-server/src/negotiation.rs b/crates/feder-server/src/negotiation.rs similarity index 52% rename from crates/feder-runtime-server/src/negotiation.rs rename to crates/feder-server/src/negotiation.rs index 0ab2177..143905f 100644 --- a/crates/feder-runtime-server/src/negotiation.rs +++ b/crates/feder-server/src/negotiation.rs @@ -102,93 +102,3 @@ fn parse_quality(value: &str) -> Option { _ => None, } } - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - fn headers(accept: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - if let Some(accept) = accept { - headers.insert(ACCEPT, HeaderValue::from_str(accept).expect("valid header")); - } - headers - } - - #[test] - fn accepts_explicit_activitypub_media_types() { - for accept in [ - Some("application/activity+json"), - Some("application/ld+json"), - Some("application/json"), - ] { - assert!(accepts_activitypub(&headers(accept)), "Accept: {accept:?}"); - } - } - - #[test] - fn rejects_implicit_html_or_unsupported_media_types() { - for accept in [ - "", - "*/*", - "application/*", - "text/html", - "application/xhtml+xml", - "image/png", - "application/activity+json;q=0", - ] { - assert!( - !accepts_activitypub(&headers(Some(accept))), - "Accept: {accept}" - ); - } - assert!(!accepts_activitypub(&headers(None))); - } - - #[test] - fn respects_quality_and_order() { - assert_eq!(parse_quality("0.9"), Some(900)); - assert_eq!(parse_quality("0.08"), Some(80)); - assert_eq!(parse_quality("1.000"), Some(1000)); - assert!(!accepts_activitypub(&headers(Some( - "application/activity+json;q=0.5, text/html;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "application/activity+json;q=0.9, text/html;q=0.8" - )))); - assert!(!accepts_activitypub(&headers(Some( - "text/html, application/activity+json" - )))); - assert!(accepts_activitypub(&headers(Some( - "application/activity+json, text/html" - )))); - assert!(!accepts_activitypub(&headers(Some("text/html, */*")))); - assert!(!accepts_activitypub(&headers(Some( - "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" - )))); - } - - #[test] - fn ignores_unsupported_types_when_comparing_supported_representations() { - assert!(!accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.5, text/html;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.8, text/html;q=0.5" - )))); - assert!(accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.5" - )))); - } - - #[test] - fn compares_the_best_type_in_each_supported_representation() { - assert!(!accepts_activitypub(&headers(Some( - "text/html;q=0.2, application/xhtml+xml;q=0.9, application/activity+json;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "text/html;q=0.8, application/activity+json;q=0.2, application/ld+json;q=0.9" - )))); - } -} 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-runtime-server/src/object.rs b/crates/feder-server/src/object.rs similarity index 69% rename from crates/feder-runtime-server/src/object.rs rename to crates/feder-server/src/object.rs index 0a282b5..4101cf7 100644 --- a/crates/feder-runtime-server/src/object.rs +++ b/crates/feder-server/src/object.rs @@ -13,45 +13,40 @@ // 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::{Object, PUBLIC_COLLECTION}; +use feder_core::{ActorDispatcher, note::is_public_note, storage::NoteStore}; use feder_vocab::Iri; -use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; +use crate::{FederServer, negotiation::accepts_activitypub}; -/// Return a persisted local ActivityPub object. -pub async fn get_object( - State(app_state): State, - Path((username, post_id)): Path<(String, String)>, +pub async fn note( + State(server): State>>, + Path((identifier, post_id)): Path<(String, String)>, headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - - let object_id = note_id(&app_state.local_actor.id, &post_id)?; - let object = app_state - .store - .lock() +) -> Result +where + A: ActorDispatcher, + S: NoteStore, +{ + let actor = server + .actors() + .get_actor(&identifier) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .load_object(&object_id) + .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)?; - let Object::Note(note) = object else { - return Err(StatusCode::NOT_FOUND); - }; - - if !note - .to - .iter() - .chain(note.cc.iter()) - .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) - { + if !is_public_note(¬e) { return Err(StatusCode::NOT_FOUND); } if !accepts_activitypub(&headers) { diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-server/src/send.rs similarity index 64% rename from crates/feder-runtime-server/src/send.rs rename to crates/feder-server/src/send.rs index 851598b..960464a 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-server/src/send.rs @@ -13,75 +13,65 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{sync::Arc, time::SystemTime}; +use std::time::SystemTime; -use crate::{config::OutboundAddressPolicy, url}; -use feder_core::{ - Activity, Recipients, SendActivity, - http_signatures::{ - ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, - }, +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}; -/// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. #[derive(Clone, Debug)] pub struct ActivitySender { client: Client, - key_pair: Arc, - key_id: String, address_policy: OutboundAddressPolicy, } impl ActivitySender { - /// Creates an activity sender for one actor identity. - pub fn new( - key_pair: Arc, - key_id: String, - address_policy: OutboundAddressPolicy, - ) -> Result { + /// 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, - key_pair, - key_id, address_policy, }) } - /// Attempts every send action and returns the first error encountered. - pub async fn send_actions(&self, actions: &[SendActivity]) -> Result<(), SendError> { - let mut first_error = None; - - for action in actions { - if let Err(error) = self.send(action).await - && first_error.is_none() - { - first_error = Some(error); + 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 } - } - - first_error.map_or(Ok(()), Err) - } - - async fn send(&self, send: &SendActivity) -> Result<(), SendError> { - let body = match &send.activity { - Activity::Accept(activity) => serde_json::to_vec(activity), - Activity::CreateNote(activity) => serde_json::to_vec(activity), - Activity::Follow(activity) => serde_json::to_vec(activity), - _ => return Err(SendError::UnsupportedActivity), - } - .map_err(SendError::Serialize)?; - let Recipients::Inbox(inbox) = &send.recipients else { - return Err(SendError::UnresolvedRecipients); + 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") { + 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| { @@ -110,14 +100,9 @@ impl ActivitySender { request_target.push('?'); request_target.push_str(query); } - let signature = sign_draft_cavage( - &self.key_pair, - &self.key_id, - "POST", - &request_target, - &headers, - ) - .map_err(SendError::Sign)?; + let signature = + sign_draft_cavage(key_pair, key_id.as_str(), "POST", &request_target, &headers) + .map_err(SendError::Sign)?; let response = self .client @@ -151,6 +136,12 @@ pub enum SendError { #[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), @@ -168,10 +159,4 @@ pub enum SendError { #[error("sending activity to {inbox} returned {status}")] UnsuccessfulStatus { inbox: String, status: StatusCode }, - - #[error("activity type is not supported for sending")] - UnsupportedActivity, - - #[error("activity recipients must resolve to an inbox before sending")] - UnresolvedRecipients, } 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..5b88da9 --- /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 + WHERE outbound_follows.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-runtime-server/src/url.rs b/crates/feder-server/src/url.rs similarity index 99% rename from crates/feder-runtime-server/src/url.rs rename to crates/feder-server/src/url.rs index f16c90a..012d7be 100644 --- a/crates/feder-runtime-server/src/url.rs +++ b/crates/feder-server/src/url.rs @@ -61,6 +61,7 @@ const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ "3fff::/20", "5f00::/16", "fc00::/7", + "fec0::/10", "fe80::/10", "ff00::/8", ]; diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-server/src/webfinger.rs similarity index 68% rename from crates/feder-runtime-server/src/webfinger.rs rename to crates/feder-server/src/webfinger.rs index c84f242..e4c3040 100644 --- a/crates/feder-runtime-server/src/webfinger.rs +++ b/crates/feder-server/src/webfinger.rs @@ -13,28 +13,23 @@ // 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}; -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, @@ -43,20 +38,42 @@ pub struct WebFingerLink { href: String, } -pub async fn webfinger( - State(state): State, +#[derive(Serialize)] +pub struct WebFingerResponse { + subject: String, + aliases: Vec, + links: Vec, +} + +pub async fn webfinger( + State(server): State>>, Query(query): Query, -) -> Result { - let Some(resource) = query.resource else { +) -> 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); - }; + } - let expected = format!("acct:{}@{}", state.username, state.handle_host); - if resource != expected { + if !resource_host.eq_ignore_ascii_case(server.handle_host()) { return Err(StatusCode::NOT_FOUND); } - let actor_id = state.local_actor.id.to_string(); + 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")], 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..a3fe486 --- /dev/null +++ b/crates/feder-server/tests/cases/inbox.rs @@ -0,0 +1,516 @@ +use axum::{ + Json, Router, + body::{Body, Bytes, to_bytes}, + http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, + routing::{get, post}, +}; +use feder_core::{ + follow::PendingFollow, + key::{create_sha256_digest_header, sign_draft_cavage}, + storage::ServerStorage, +}; +use feder_server::{InboxAuthPolicy, build_router, storage::SqliteStore}; +use feder_vocab::{Actor, Iri}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::common::{ + HANDLE_HOST, ORIGIN, RecordedRequest, actor_key_pair, iri, test_router, + test_router_with_policy, test_server_with_store, +}; + +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 { + post_signed_inbox_with_algorithm( + app, + uri, + actor_id, + signed_body, + delivered_body, + host, + Some("rsa-sha256"), + ) + .await +} + +async fn post_signed_inbox_with_algorithm( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, + algorithm: Option<&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 mut signature = sign_draft_cavage( + &actor_key_pair(), + &format!("{actor_id}#main-key"), + "POST", + uri, + &headers, + ) + .expect("sign inbox request"); + if algorithm != Some("rsa-sha256") { + signature = match algorithm { + Some(algorithm) => signature.replacen( + "algorithm=\"rsa-sha256\"", + &format!("algorithm=\"{algorithm}\""), + 1, + ), + None => signature.replacen("algorithm=\"rsa-sha256\",", "", 1), + }; + } + + 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") +} + +fn accept_body(actor_id: &str, follow_id: &Iri) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Accept", + "id": format!("{actor_id}/accepts/1"), + "actor": actor_id, + "object": follow_id, + })) + .expect("serialize Accept") +} + +async fn assert_valid_accept_is_confirmed(inbox_uri: &str) { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let directory = tempfile::tempdir().expect("create temporary database directory"); + let database_path = directory.path().join("feder.sqlite"); + let storage = SqliteStore::open(&database_path).expect("open store"); + let follow_id = iri(&format!("{ORIGIN}/users/alice/activities/follow/1")); + let pending = PendingFollow { + local_actor: iri(&format!("{ORIGIN}/users/alice")), + remote_actor: Actor::person( + iri(&actor_id), + iri(&format!("{actor_id}/inbox")), + iri(&format!("{actor_id}/outbox")), + ), + follow_activity: follow_id.clone(), + }; + storage + .store_pending_follow(&pending) + .expect("store pending Follow"); + let app = build_router(test_server_with_store( + storage, + InboxAuthPolicy::RequireSigned, + )); + let body = accept_body(&actor_id, &follow_id); + + let response = + post_signed_inbox(app, inbox_uri, &actor_id, &body, body.clone(), HANDLE_HOST).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let observer = SqliteStore::open(&database_path).expect("reopen store"); + assert_eq!( + observer + .load_pending_follow(&follow_id) + .expect("load accepted Follow"), + None + ); + remote_server.abort(); +} + +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 signed_follow_accepts_hs2019_algorithm() { + 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_with_algorithm( + app, + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + Some("hs2019"), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_accepts_omitted_algorithm() { + 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_with_algorithm( + app, + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn personal_inbox_confirms_a_valid_accept() { + assert_valid_accept_is_confirmed("/users/alice/inbox").await; +} + +#[tokio::test] +async fn shared_inbox_confirms_a_valid_accept() { + assert_valid_accept_is_confirmed("/inbox").await; +} + +#[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 ignores_accept_and_undo_of_unsupported_activities() { + for kind in ["Accept", "Undo"] { + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": kind, + "id": format!("https://remote.example/activities/{kind}-1"), + "actor": "https://remote.example/users/bob", + "object": { + "type": "Like", + "id": "https://remote.example/activities/like-1", + "actor": "https://remote.example/users/bob", + "object": format!("{ORIGIN}/users/alice/posts/1") + } + })) + .expect("serialize unsupported nested activity"); + + let response = post_inbox( + test_router(), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED, "{kind}"); + } +} + +#[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..389922c --- /dev/null +++ b/crates/feder-server/tests/cases/operation.rs @@ -0,0 +1,184 @@ +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, + test_server_with_store, +}; + +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"), + "endpoints": { + "sharedInbox": format!("http://{address}/shared-inbox") + } + }); + let (sender, receiver) = tokio::sync::mpsc::channel(2); + let shared_sender = sender.clone(); + 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 + } + }, + ), + ) + .route( + "/shared-inbox", + axum::routing::post( + move |headers: axum::http::HeaderMap, + uri: axum::http::Uri, + body: axum::body::Bytes| { + let sender = shared_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 directory = tempfile::tempdir().expect("create temporary database directory"); + let database_path = directory.path().join("feder.sqlite"); + let storage = SqliteStore::open(&database_path).expect("open store"); + let server = test_server_with_store(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_eq!(request.uri.path(), "/inbox"); + 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()); + let observer = SqliteStore::open(&database_path).expect("reopen store"); + let pending = observer + .load_pending_follow(&follow_id) + .expect("load pending Follow") + .expect("pending Follow was persisted"); + assert_eq!(pending.local_actor, local_actor_id); + assert_eq!(pending.remote_actor.id, remote_actor_id); + assert_eq!(pending.follow_activity, follow_id); + 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..6f2bc5c --- /dev/null +++ b/crates/feder-server/tests/common/mod.rs @@ -0,0 +1,150 @@ +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) +} + +pub 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 storage = SqliteStore::open_in_memory().expect("open in-memory store"); + configure(&storage); + test_server_with_store(storage, inbox_auth_policy) +} + +pub fn test_server_with_store( + storage: SqliteStore, + inbox_auth_policy: InboxAuthPolicy, +) -> FederServer { + let actor = local_actor(); + storage + .insert_actor_key_pair(&actor.id, &actor_key_pair()) + .expect("store actor key pair"); + 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-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/feder-server/tests/fixtures/rsa-private-key.pem similarity index 100% rename from crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem rename to crates/feder-server/tests/fixtures/rsa-private-key.pem diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/feder-server/tests/fixtures/rsa-public-key.pem similarity index 100% rename from crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem rename to crates/feder-server/tests/fixtures/rsa-public-key.pem 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..a5f405b --- /dev/null +++ b/crates/feder-server/tests/storage.rs @@ -0,0 +1,191 @@ +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 storing_pending_follow_does_not_reopen_an_accepted_relationship() { + 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"); + assert!( + store + .confirm_pending_follow(&pending) + .expect("confirm pending Follow") + ); + + let mut replacement = pending.clone(); + replacement.remote_actor = actor("https://remote.example/users/mallory"); + store + .store_pending_follow(&replacement) + .expect("retry storing accepted 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 a22cb34..f738980 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -160,6 +160,31 @@ where } } +fn deserialize_addressing<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + fn parse_iri(value: String) -> Result + where + E: serde::de::Error, + { + let value = match value.as_str() { + "Public" | "as:Public" => "https://www.w3.org/ns/activitystreams#Public", + _ => &value, + }; + value.parse().map_err(E::custom) + } + + match OneOrMany::::deserialize(deserializer)? { + OneOrMany::One(value) => parse_iri(value).map(References::one), + OneOrMany::Many(values) => values + .into_iter() + .map(parse_iri) + .collect::, _>>() + .map(References::many), + } +} + macro_rules! activitystreams_type { ($name:ident, $variant:ident) => { #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -329,9 +354,17 @@ 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")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub to: References, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub cc: References, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, @@ -462,9 +495,17 @@ pub struct Create { pub id: Iri, pub actor: Reference, pub object: Reference, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub to: References, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub cc: References, } @@ -642,6 +683,27 @@ mod tests { assert_eq!(roundtrip(&create), create); } + #[test] + fn addressing_normalizes_public_compact_terms() { + for public_address in ["Public", "as:Public"] { + let note: Note = serde_json::from_value(json!({ + "type": "Note", + "id": "https://example.com/notes/1", + "to": public_address, + })) + .expect("deserialize compact Public term"); + + assert_eq!( + note.to, + References::one(iri("https://www.w3.org/ns/activitystreams#Public")) + ); + assert_eq!( + serde_json::to_value(note).expect("serialize normalized Note")["to"], + json!("https://www.w3.org/ns/activitystreams#Public") + ); + } + } + #[test] fn concrete_types_reject_wrong_activitystreams_type() { let result = serde_json::from_value::(json!({ 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 e5527cc..f9a4aec 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -1,7 +1,8 @@ Single-User Server Example ========================== -Demo app using `feder-runtime-server` with one hardcoded local actor. +Demo app using `feder-server` with one hardcoded local actor and +the built-in SQLite storage adapter. Run @@ -13,6 +14,9 @@ The example currently targets Linux: 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: ~~~~ text @@ -25,14 +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 +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice ~~~~ -Expected response: +Discover the actor through WebFinger: -~~~~ text -HTTP/1.1 204 No Content +~~~~ sh +curl -i \ + 'http://127.0.0.1:3000/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000' ~~~~ diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index d4d1f37..f478bcf 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,49 +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, OutboundAddressPolicy, RuntimeConfig, StorageConfig, build_router, +use std::{convert::Infallible, env, error::Error, net::SocketAddr, path::PathBuf}; + +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 default_local() -> RuntimeConfig { - RuntimeConfig { - bind: "127.0.0.1:3000" + 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, - outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, - 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 8c9e654..1bf199d 100644 --- a/mise.toml +++ b/mise.toml @@ -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 = (