diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 714ba89..ef294a4 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -338,9 +338,11 @@ dependencies = [ "jsonwebtoken", "models", "once_cell", + "reqwest", "sea-orm", "serde", "serde_json", + "tokio", "tower 0.5.2", "tower-cookies", "tower-http", @@ -977,6 +979,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1435,6 +1447,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1888,6 +1915,22 @@ dependencies = [ "webpki-roots 1.0.1", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.14" @@ -1907,9 +1950,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2388,6 +2433,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -2494,6 +2556,50 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -3029,22 +3135,27 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http 1.3.1", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3055,6 +3166,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.2", @@ -3301,6 +3413,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "scoped-futures" version = "0.1.4" @@ -3490,6 +3611,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.26" @@ -4125,6 +4269,27 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4137,6 +4302,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4293,6 +4471,16 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.13" @@ -5096,6 +5284,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/thoughts-backend/api/Cargo.toml b/thoughts-backend/api/Cargo.toml index 390f85e..dc312f3 100644 --- a/thoughts-backend/api/Cargo.toml +++ b/thoughts-backend/api/Cargo.toml @@ -18,12 +18,7 @@ bcrypt = "0.17.1" jsonwebtoken = "9.3.1" once_cell = "1.21.3" -tower-http = { version = "0.6.6", features = ["fs", "cors"] } -tower-cookies = "0.11.0" -anyhow = "1.0.98" -dotenvy = "0.15.7" -activitypub_federation = "0.6.5" -url = "2.5.7" +tokio = "1.45.1" # db sea-orm = { workspace = true } @@ -36,6 +31,13 @@ serde_json = { workspace = true } # local dependencies app = { path = "../app" } models = { path = "../models" } +reqwest = { version = "0.12.23", features = ["json"] } +tower-http = { version = "0.6.6", features = ["fs", "cors"] } +tower-cookies = "0.11.0" +anyhow = "1.0.98" +dotenvy = "0.15.7" +activitypub_federation = "0.6.5" +url = "2.5.7" [dev-dependencies] diff --git a/thoughts-backend/api/src/federation.rs b/thoughts-backend/api/src/federation.rs new file mode 100644 index 0000000..a85a4ce --- /dev/null +++ b/thoughts-backend/api/src/federation.rs @@ -0,0 +1,71 @@ +use app::{ + persistence::{follow, user}, + state::AppState, +}; +use models::domains::thought; +use serde_json::json; + +// This function handles pushing a new thought to all followers. +pub async fn federate_thought( + state: AppState, + thought: thought::Model, + author: models::domains::user::Model, +) { + // Find all followers of the author + let follower_ids = match follow::get_follower_ids(&state.conn, author.id).await { + Ok(ids) => ids, + Err(e) => { + tracing::error!("Failed to get followers for federation: {}", e); + return; + } + }; + + if follower_ids.is_empty() { + tracing::debug!("No followers to federate to for user {}", author.username); + return; + } + + let base_url = "http://localhost:3000"; // Replace in production + let thought_url = format!("{}/thoughts/{}", base_url, thought.id); + let author_url = format!("{}/users/{}", base_url, author.username); + + // Construct the "Create" activity containing the "Note" object + let activity = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": format!("{}/activity", thought_url), + "type": "Create", + "actor": author_url, + "object": { + "id": thought_url, + "type": "Note", + "attributedTo": author_url, + "content": thought.content, + "published": thought.created_at.to_rfc3339(), + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [format!("{}/followers", author_url)] + } + }); + + // Get the inbox URLs for all followers + // In a real federated app, you would store remote users' full inbox URLs. + // For now, we assume followers are local and construct their inbox URLs. + let followers = match user::get_users_by_ids(&state.conn, follower_ids).await { + Ok(users) => users, + Err(e) => { + tracing::error!("Failed to get follower user objects: {}", e); + return; + } + }; + + let client = reqwest::Client::new(); + for follower in followers { + let inbox_url = format!("{}/users/{}/inbox", base_url, follower.username); + tracing::info!("Federating post {} to {}", thought.id, inbox_url); + + let res = client.post(&inbox_url).json(&activity).send().await; + + if let Err(e) = res { + tracing::error!("Failed to federate to {}: {}", inbox_url, e); + } + } +} diff --git a/thoughts-backend/api/src/lib.rs b/thoughts-backend/api/src/lib.rs index ceba9b9..12a96f9 100644 --- a/thoughts-backend/api/src/lib.rs +++ b/thoughts-backend/api/src/lib.rs @@ -1,5 +1,6 @@ mod error; mod extractor; +mod federation; mod init; mod validation; diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs index 2455970..edc89fc 100644 --- a/thoughts-backend/api/src/routers/thought.rs +++ b/thoughts-backend/api/src/routers/thought.rs @@ -16,6 +16,7 @@ use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSche use crate::{ error::ApiError, extractor::{AuthUser, Json, Valid}, + federation, models::{ApiErrorResponse, ParamsErrorResponse}, }; @@ -43,6 +44,13 @@ async fn thoughts_post( .await? .ok_or(UserError::NotFound)?; // Should not happen if auth is valid + // Spawn a background task to handle federation without blocking the response + tokio::spawn(federation::federate_thought( + state.clone(), + thought.clone(), + author.clone(), + )); + let schema = ThoughtSchema::from_models(&thought, &author); Ok((StatusCode::CREATED, Json(schema))) } diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index b8ef3e3..431d502 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -252,6 +252,67 @@ async fn get_user_by_param( } } +#[utoipa::path( + get, + path = "/{username}/outbox", + description = "The ActivityPub outbox for sending activities.", + responses( + (status = 200, description = "Activity collection", body = Object), + (status = 404, description = "User not found") + ) +)] +async fn user_outbox_get( + State(state): State, + Path(username): Path, +) -> Result { + let user = get_user_by_username(&state.conn, &username) + .await? + .ok_or(UserError::NotFound)?; + + let thoughts = get_thoughts_by_user(&state.conn, user.id).await?; + + // Format the outbox as an ActivityPub OrderedCollection + let base_url = "http://localhost:3000"; + let outbox_url = format!("{}/users/{}/outbox", base_url, username); + let items: Vec = thoughts + .into_iter() + .map(|thought| { + let thought_url = format!("{}/thoughts/{}", base_url, thought.id); + let author_url = format!("{}/users/{}", base_url, thought.author_username); + json!({ + "id": format!("{}/activity", thought_url), + "type": "Create", + "actor": author_url, + "published": thought.created_at, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + "id": thought_url, + "type": "Note", + "attributedTo": author_url, + "content": thought.content, + "published": thought.created_at, + } + }) + }) + .collect(); + + let outbox = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": outbox_url, + "type": "OrderedCollection", + "totalItems": items.len(), + "orderedItems": items, + }); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + "application/activity+json".parse().unwrap(), + ); + + Ok((headers, Json(outbox))) +} + pub fn create_user_router() -> Router { Router::new() .route("/", get(users_get)) @@ -262,4 +323,5 @@ pub fn create_user_router() -> Router { post(user_follow_post).delete(user_follow_delete), ) .route("/{username}/inbox", post(user_inbox_post)) + .route("/{username}/outbox", get(user_outbox_get)) } diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs index 08af323..6034d14 100644 --- a/thoughts-backend/app/src/persistence/follow.rs +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -66,3 +66,11 @@ pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result, DbE Ok(followed_users.into_iter().map(|f| f.followed_id).collect()) } + +pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result, DbErr> { + let followers = follow::Entity::find() + .filter(follow::Column::FollowedId.eq(user_id)) + .all(db) + .await?; + Ok(followers.into_iter().map(|f| f.follower_id).collect()) +} diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index a3f7a9e..b0f50c8 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -36,3 +36,10 @@ pub async fn get_user_by_username( .one(db) .await } + +pub async fn get_users_by_ids(db: &DbConn, ids: Vec) -> Result, DbErr> { + user::Entity::find() + .filter(user::Column::Id.is_in(ids)) + .all(db) + .await +} diff --git a/thoughts-backend/doc/src/user.rs b/thoughts-backend/doc/src/user.rs index f456d4e..89d027d 100644 --- a/thoughts-backend/doc/src/user.rs +++ b/thoughts-backend/doc/src/user.rs @@ -17,6 +17,7 @@ use models::schemas::{ user_follow_post, user_follow_delete, user_inbox_post, + user_outbox_get, ), components(schemas( CreateUserParams, diff --git a/thoughts-backend/tests/api/activitypub.rs b/thoughts-backend/tests/api/activitypub.rs index 907a7ab..c7584b0 100644 --- a/thoughts-backend/tests/api/activitypub.rs +++ b/thoughts-backend/tests/api/activitypub.rs @@ -2,7 +2,9 @@ use crate::api::main::{create_user_with_password, setup}; use axum::http::{header, StatusCode}; use http_body_util::BodyExt; use serde_json::{json, Value}; -use utils::testing::{make_get_request, make_post_request, make_request_with_headers}; +use utils::testing::{ + make_get_request, make_jwt_request, make_post_request, make_request_with_headers, +}; #[tokio::test] async fn test_webfinger_discovery() { @@ -100,4 +102,45 @@ async fn test_user_inbox_follow() { !following.contains(&2), "User1 should now be followed by user2" ); + assert!(following.is_empty(), "User1 should not be following anyone"); +} + +#[tokio::test] +async fn test_user_outbox_get() { + let app = setup().await; + create_user_with_password(&app.db, "testuser", "password123").await; + let token = super::main::login_user(app.router.clone(), "testuser", "password123").await; + + // Create a thought first + let thought_body = json!({ "content": "This is a federated thought!" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(thought_body), + &token, + ) + .await; + + // Now, fetch the outbox + let response = make_request_with_headers( + app.router.clone(), + "/users/testuser/outbox", + "GET", + None, + vec![(header::ACCEPT, "application/activity+json")], + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(v["type"], "OrderedCollection"); + assert_eq!(v["totalItems"], 1); + assert_eq!(v["orderedItems"][0]["type"], "Create"); + assert_eq!( + v["orderedItems"][0]["object"]["content"], + "This is a federated thought!" + ); }