diff --git a/Cargo.lock b/Cargo.lock index 873cd73..2d58288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2017,9 +2017,9 @@ dependencies = [ [[package]] name = "k-ap" -version = "0.3.0" +version = "0.3.1" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" -checksum = "ce46ce331b0c85e5d9e1874056266d78d6a478f35ae188586fe488d840596346" +checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d" dependencies = [ "activitypub_federation", "anyhow", @@ -4570,7 +4570,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2f26a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.DEFAULT_GOAL := check + +# Run the full local check suite — same order as CI would. +check: fmt-check clippy test + @echo "✅ All checks passed" + +# Apply rustfmt to all files. +fmt: + cargo fmt + +# Check formatting without modifying files (CI-safe). +fmt-check: + cargo fmt --check + +# Run Clippy and treat warnings as errors. +clippy: + cargo clippy -- -D warnings + +# Run the test suite. +test: + cargo test + +# Apply fmt + clippy auto-fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +.PHONY: check fmt fmt-check clippy test fix diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index 45264a2..bc7e8af 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -k-ap = { version = "0.3.0", registry = "gitea" } +k-ap = { version = "0.3.1", registry = "gitea" } domain = { workspace = true } url = { workspace = true } serde = { workspace = true } diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs index abb59b6..75e5cb7 100644 --- a/crates/adapters/activitypub/src/port.rs +++ b/crates/adapters/activitypub/src/port.rs @@ -73,7 +73,6 @@ pub trait ActivityPubRepository: Send + Sync { // ── Inbox processing (remote → local) ─────────────────────────── /// Persist an incoming remote Note. Idempotent on ap_id. - async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result; /// Apply an Update to a previously accepted remote Note. diff --git a/crates/adapters/activitypub/src/service.rs b/crates/adapters/activitypub/src/service.rs index b0493e8..532255b 100644 --- a/crates/adapters/activitypub/src/service.rs +++ b/crates/adapters/activitypub/src/service.rs @@ -114,11 +114,11 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor { avatar_url: a.avatar_url, outbox_url: a.outbox_url, last_fetched_at: chrono::Utc::now(), - bio: None, - banner_url: None, - also_known_as: None, - followers_url: None, - following_url: None, + bio: a.bio, + banner_url: a.banner_url, + also_known_as: a.also_known_as.into_iter().next(), + followers_url: a.followers_url, + following_url: a.following_url, attachment: vec![], } } diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index 382f52a..7a260eb 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -k-ap = { version = "0.3.0", registry = "gitea" } +k-ap = { version = "0.3.1", registry = "gitea" } sqlx = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index b5e8232..0c08ad6 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -35,7 +35,8 @@ fn str_status(s: &str) -> FollowerStatus { } } -fn map_remote_actor( +#[derive(sqlx::FromRow)] +struct RemoteActorRow { url: String, handle: String, inbox_url: String, @@ -43,15 +44,27 @@ fn map_remote_actor( display_name: Option, avatar_url: Option, outbox_url: Option, -) -> RemoteActor { + bio: Option, + banner_url: Option, + followers_url: Option, + following_url: Option, + also_known_as: Option>, +} + +fn map_remote_actor(r: RemoteActorRow) -> RemoteActor { RemoteActor { - url, - handle, - inbox_url, - shared_inbox_url, - display_name, - avatar_url, - outbox_url, + url: r.url, + handle: r.handle, + inbox_url: r.inbox_url, + shared_inbox_url: r.shared_inbox_url, + display_name: r.display_name, + avatar_url: r.avatar_url, + outbox_url: r.outbox_url, + bio: r.bio, + banner_url: r.banner_url, + followers_url: r.followers_url, + following_url: r.following_url, + also_known_as: r.also_known_as.unwrap_or_default(), } } @@ -143,18 +156,15 @@ impl FollowRepository for PostgresFederationRepository { async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { #[derive(sqlx::FromRow)] struct Row { - remote_actor_url: String, + #[sqlx(flatten)] + actor: RemoteActorRow, status: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, } sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + "SELECT f.remote_actor_url AS url, f.status, + COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, + r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_followers f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1 AND f.status='accepted'", @@ -166,15 +176,7 @@ impl FollowRepository for PostgresFederationRepository { .map(|rows| { rows.into_iter() .map(|r| Follower { - actor: map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ), + actor: map_remote_actor(r.actor), status: str_status(&r.status), }) .collect() @@ -189,18 +191,15 @@ impl FollowRepository for PostgresFederationRepository { ) -> Result> { #[derive(sqlx::FromRow)] struct Row { - remote_actor_url: String, + #[sqlx(flatten)] + actor: RemoteActorRow, status: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, } sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + "SELECT f.remote_actor_url AS url, f.status, + COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, + r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_followers f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1 AND f.status='accepted' @@ -215,15 +214,7 @@ impl FollowRepository for PostgresFederationRepository { .map(|rows| { rows.into_iter() .map(|r| Follower { - actor: map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ), + actor: map_remote_actor(r.actor), status: str_status(&r.status), }) .collect() @@ -258,19 +249,10 @@ impl FollowRepository for PostgresFederationRepository { offset: u32, limit: usize, ) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - remote_actor_url: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, - } - sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + sqlx::query_as::<_, RemoteActorRow>( + "SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_followers f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1 AND f.status='accepted' @@ -282,21 +264,7 @@ impl FollowRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await .map_err(|e| anyhow!(e)) - .map(|rows| { - rows.into_iter() - .map(|r| { - map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ) - }) - .collect() - }) + .map(|rows| rows.into_iter().map(map_remote_actor).collect()) } async fn get_accepted_follower_inboxes( @@ -325,19 +293,10 @@ impl FollowRepository for PostgresFederationRepository { } async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - remote_actor_url: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, - } - sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + sqlx::query_as::<_, RemoteActorRow>( + "SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_followers f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1 AND f.status='pending'", @@ -346,21 +305,7 @@ impl FollowRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await .map_err(|e| anyhow!(e)) - .map(|rows| { - rows.into_iter() - .map(|r| { - map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ) - }) - .collect() - }) + .map(|rows| rows.into_iter().map(map_remote_actor).collect()) } async fn update_follower_status( @@ -432,19 +377,10 @@ impl FollowRepository for PostgresFederationRepository { } async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - remote_actor_url: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, - } - sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + sqlx::query_as::<_, RemoteActorRow>( + "SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_following f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1", @@ -453,21 +389,7 @@ impl FollowRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await .map_err(|e| anyhow!(e)) - .map(|rows| { - rows.into_iter() - .map(|r| { - map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ) - }) - .collect() - }) + .map(|rows| rows.into_iter().map(map_remote_actor).collect()) } async fn get_following_page( @@ -476,19 +398,10 @@ impl FollowRepository for PostgresFederationRepository { offset: u32, limit: usize, ) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - remote_actor_url: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, - } - sqlx::query_as::<_, Row>( - "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, - COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + sqlx::query_as::<_, RemoteActorRow>( + "SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url, + r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as FROM federation_following f LEFT JOIN remote_actors r ON r.url=f.remote_actor_url WHERE f.local_user_id=$1 @@ -500,21 +413,7 @@ impl FollowRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await .map_err(|e| anyhow!(e)) - .map(|rows| { - rows.into_iter() - .map(|r| { - map_remote_actor( - r.remote_actor_url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ) - }) - .collect() - }) + .map(|rows| rows.into_iter().map(map_remote_actor).collect()) } async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { @@ -626,12 +525,22 @@ impl ActorRepository for PostgresFederationRepository { } async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { + let also_known_as: Option> = if actor.also_known_as.is_empty() { + None + } else { + Some(actor.also_known_as) + }; sqlx::query( - "INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at) - VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW()) - ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name, - inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url, - avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()", + "INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key, + avatar_url,outbox_url,bio,banner_url,followers_url,following_url,also_known_as,last_fetched_at) + VALUES($1,$2,$3,$4,$5,'',$6,$7,$8,$9,$10,$11,$12,NOW()) + ON CONFLICT(url) DO UPDATE SET + handle=EXCLUDED.handle, display_name=EXCLUDED.display_name, + inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url, + avatar_url=EXCLUDED.avatar_url, outbox_url=EXCLUDED.outbox_url, + bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, + followers_url=EXCLUDED.followers_url, following_url=EXCLUDED.following_url, + also_known_as=EXCLUDED.also_known_as, last_fetched_at=NOW()", ) .bind(&actor.url) .bind(&actor.handle) @@ -640,6 +549,11 @@ impl ActorRepository for PostgresFederationRepository { .bind(&actor.shared_inbox_url) .bind(&actor.avatar_url) .bind(&actor.outbox_url) + .bind(&actor.bio) + .bind(&actor.banner_url) + .bind(&actor.followers_url) + .bind(&actor.following_url) + .bind(also_known_as.as_deref()) .execute(&self.pool) .await .map_err(|e| anyhow!(e)) @@ -647,36 +561,16 @@ impl ActorRepository for PostgresFederationRepository { } async fn get_remote_actor(&self, actor_url: &str) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - url: String, - handle: String, - inbox_url: String, - shared_inbox_url: Option, - display_name: Option, - avatar_url: Option, - outbox_url: Option, - } - sqlx::query_as::<_, Row>( - "SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1", + sqlx::query_as::<_, RemoteActorRow>( + "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, + bio, banner_url, followers_url, following_url, also_known_as + FROM remote_actors WHERE url=$1", ) .bind(actor_url) .fetch_optional(&self.pool) .await .map_err(|e| anyhow!(e)) - .map(|o| { - o.map(|r| { - map_remote_actor( - r.url, - r.handle, - r.inbox_url, - r.shared_inbox_url, - r.display_name, - r.avatar_url, - r.outbox_url, - ) - }) - }) + .map(|o| o.map(map_remote_actor)) } async fn add_announce( @@ -827,6 +721,17 @@ impl BlocklistRepository for PostgresFederationRepository { // ── PostgresApUserRepository ────────────────────────────────────────────────── +#[derive(sqlx::FromRow)] +struct UserRow { + id: uuid::Uuid, + username: String, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + also_known_as: Option, +} + pub struct PostgresApUserRepository { pool: PgPool, base_url: String, @@ -837,27 +742,18 @@ impl PostgresApUserRepository { Self { pool, base_url } } - fn row_to_ap_user( - &self, - id: uuid::Uuid, - username: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - also_known_as: Option, - ) -> ApUser { - let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok(); - let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok()); - let banner_url = header_url.and_then(|u| url::Url::parse(&u).ok()); + fn row_to_ap_user(&self, r: UserRow) -> ApUser { + let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.username)).ok(); + let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok()); + let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok()); ApUser { - id, - username, - display_name, - bio, + id: r.id, + username: r.username, + display_name: r.display_name, + bio: r.bio, avatar_url, banner_url, - also_known_as: also_known_as.into_iter().collect(), + also_known_as: r.also_known_as.into_iter().collect(), profile_url, attachment: vec![], manually_approves_followers: false, @@ -871,65 +767,25 @@ impl PostgresApUserRepository { #[async_trait] impl ApUserRepository for PostgresApUserRepository { async fn find_by_id(&self, id: uuid::Uuid) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - id: uuid::Uuid, - username: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - also_known_as: Option, - } - let row = sqlx::query_as::<_, Row>( + let row = sqlx::query_as::<_, UserRow>( "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true", ) .bind(id) .fetch_optional(&self.pool) .await .map_err(|e| anyhow!(e))?; - Ok(row.map(|r| { - self.row_to_ap_user( - r.id, - r.username, - r.display_name, - r.bio, - r.avatar_url, - r.header_url, - r.also_known_as, - ) - })) + Ok(row.map(|r| self.row_to_ap_user(r))) } async fn find_by_username(&self, username: &str) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - id: uuid::Uuid, - username: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - also_known_as: Option, - } - let row = sqlx::query_as::<_, Row>( + let row = sqlx::query_as::<_, UserRow>( "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true", ) .bind(username) .fetch_optional(&self.pool) .await .map_err(|e| anyhow!(e))?; - Ok(row.map(|r| { - self.row_to_ap_user( - r.id, - r.username, - r.display_name, - r.bio, - r.avatar_url, - r.header_url, - r.also_known_as, - ) - })) + Ok(row.map(|r| self.row_to_ap_user(r))) } async fn count_users(&self) -> Result { diff --git a/crates/adapters/postgres/migrations/015_remote_actors_rich_fields.sql b/crates/adapters/postgres/migrations/015_remote_actors_rich_fields.sql new file mode 100644 index 0000000..ba41524 --- /dev/null +++ b/crates/adapters/postgres/migrations/015_remote_actors_rich_fields.sql @@ -0,0 +1,6 @@ +ALTER TABLE remote_actors + ADD COLUMN IF NOT EXISTS bio TEXT, + ADD COLUMN IF NOT EXISTS banner_url TEXT, + ADD COLUMN IF NOT EXISTS followers_url TEXT, + ADD COLUMN IF NOT EXISTS following_url TEXT, + ADD COLUMN IF NOT EXISTS also_known_as TEXT[]; diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index ed7f287..cd30859 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -14,7 +14,7 @@ postgres = { workspace = true } postgres-search = { workspace = true } postgres-federation = { workspace = true } activitypub = { workspace = true } -k-ap = { version = "0.3.0", registry = "gitea" } +k-ap = { version = "0.3.1", registry = "gitea" } serde_json = { workspace = true } anyhow = { workspace = true } nats = { workspace = true } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 825d5a6..155b755 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -13,7 +13,7 @@ application = { workspace = true } nats = { workspace = true } event-transport = { workspace = true } event-payload = { workspace = true } -k-ap = { version = "0.3.0", registry = "gitea" } +k-ap = { version = "0.3.1", registry = "gitea" } activitypub = { workspace = true } postgres = { workspace = true } postgres-federation = { workspace = true }