From 23eddb4593540f0103fab66cf1c6ef4748efb108 Mon Sep 17 00:00:00 2001 From: rtkay123 Date: Tue, 22 Jul 2025 17:06:46 +0200 Subject: feat: follower ordered collections --- contrib/bruno/users/followers.bru | 6 +- crates/sellershut/src/entity/user.rs | 6 +- crates/sellershut/src/entity/user/followers.rs | 256 +++++++++----------- .../src/entity/user/followers/followers_page.rs | 243 +++++++++++++++++++ crates/sellershut/src/server.rs | 13 +- crates/sellershut/src/server/activities/follow.rs | 2 +- .../src/server/routes/users/followers.rs | 259 +++++++++++++++++++-- crates/sellershut/src/state.rs | 7 +- 8 files changed, 621 insertions(+), 171 deletions(-) diff --git a/contrib/bruno/users/followers.bru b/contrib/bruno/users/followers.bru index e394d30..794e286 100644 --- a/contrib/bruno/users/followers.bru +++ b/contrib/bruno/users/followers.bru @@ -5,11 +5,15 @@ meta { } get { - url: {{HUT_HOSTNAME}}/users/sellershut/followers + url: http://localhost:2210/users/sellershut/followers?cursor=aHR0cDovL2xvY2FsaG9zdDoyMjEwL2FjdGl2aXR5L2ZvbGxvdy9ESmdfZE9oT2h4QUtsOVdESXhXSmp8MjAyNS0wNy0yMFQxMDo1OTozOC41MTgxMTla body: none auth: inherit } +params:query { + cursor: aHR0cDovL2xvY2FsaG9zdDoyMjEwL2FjdGl2aXR5L2ZvbGxvdy9ESmdfZE9oT2h4QUtsOVdESXhXSmp8MjAyNS0wNy0yMFQxMDo1OTozOC41MTgxMTla +} + assert { res.status: eq 200 } diff --git a/crates/sellershut/src/entity/user.rs b/crates/sellershut/src/entity/user.rs index d58f4eb..e3af58c 100644 --- a/crates/sellershut/src/entity/user.rs +++ b/crates/sellershut/src/entity/user.rs @@ -226,7 +226,11 @@ pub struct PartialPerson { impl From for PartialPerson { fn from(value: Person) -> Self { - Self{ kind: value.kind, preferred_username: value.preferred_username, id: value.id } + Self { + kind: value.kind, + preferred_username: value.preferred_username, + id: value.id, + } } } diff --git a/crates/sellershut/src/entity/user/followers.rs b/crates/sellershut/src/entity/user/followers.rs index 9f60dde..e8fc0e1 100644 --- a/crates/sellershut/src/entity/user/followers.rs +++ b/crates/sellershut/src/entity/user/followers.rs @@ -1,8 +1,12 @@ +pub mod followers_page; + use activitypub_federation::{ config::Data, + fetch::object_id::ObjectId, kinds::collection::{OrderedCollectionPageType, OrderedCollectionType}, traits::Object, }; +use anyhow::anyhow; use async_trait::async_trait; use base64::{Engine, engine::general_purpose}; use serde::{Deserialize, Serialize}; @@ -11,22 +15,24 @@ use tracing::trace; use url::Url; use crate::{ - entity::user::{DbUser, PartialPerson, Person, User, UserType}, + entity::user::{ + DbUser, PartialPerson, User, UserType, followers::followers_page::FollowersPage, + }, error::AppError, - server::activities::follow::Follow, + server::{PAGINATION_LIMIT, activities::follow::Follow}, state::AppHandle, }; #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Follower { - pub(crate) user_id: String, - pub(crate) cursor: Option, +pub struct DbFollower { + pub(crate) username: String, + pub(crate) path: String, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FollowersCollection { - id: Url, + id: ObjectId, #[serde(rename = "type")] kind: OrderedCollectionType, #[serde(skip_serializing_if = "Option::is_none")] @@ -36,21 +42,8 @@ pub struct FollowersCollection { first: Option, } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FollowersPage { - id: Url, - #[serde(rename = "type")] - kind: OrderedCollectionPageType, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - ordered_items: Vec, - next: Option, - part_of: Url, -} - #[derive(Serialize)] -struct Row { +pub struct FollowerRow { activity: sqlx::types::Json, id: String, description: Option, @@ -65,16 +58,11 @@ struct Row { updated_at: OffsetDateTime, created_at: OffsetDateTime, user_type: UserType, -} - -struct DbFollower { - user_type: UserType, - ap_id: String, - username: String, + follow_date: OffsetDateTime, } #[async_trait] -impl Object for Follower { +impl Object for DbFollower { #[doc = " App data type passed to handlers. Must be identical to"] #[doc = " [crate::config::FederationConfigBuilder::app_data] type."] type DataType = AppHandle; @@ -95,9 +83,22 @@ impl Object for Follower { #[doc = " Should return `Ok(None)` if not found."] async fn read_from_id( object_id: Url, - data: &Data, + _data: &Data, ) -> Result, Self::Error> { - todo!() + // /users/myuser + let mut paths = object_id + .path_segments() + .ok_or_else(|| anyhow!("missing paths"))?; + paths.next(); + let username = paths + .next() + .ok_or_else(|| anyhow!("missing username"))? + .to_owned(); + + Ok(Some(Self { + username, + path: object_id.path().to_owned(), + })) } #[doc = " Convert database type to Activitypub type."] @@ -106,81 +107,20 @@ impl Object for Follower { #[doc = " gets sent in an activity."] async fn into_json(self, data: &Data) -> Result { let domain = data.domain(); - trace!(domain = domain, "setting domain"); - let stub = self.user_id.replace("/followers", ""); - let actor = Url::parse(&format!("http://{domain}{stub}"))?.to_string(); - trace!("{actor}"); + let actor = Url::parse(&format!( + "{}://{domain}/users/{}", + data.protocol, self.username + ))? + .to_string(); - let transaction: Result, _> = if let Some(cursor) = self.cursor { - trace!("getting with pagination"); - let result = general_purpose::STANDARD.decode(cursor)?; - let tokens = String::from_utf8_lossy(result.as_slice()); - let mut tokens = tokens.split("|"); - let id = tokens.next().unwrap(); - let ts = tokens.next().unwrap(); - let ts = OffsetDateTime::parse(ts, &Rfc3339)?; - sqlx::query_as!( - Row, - r#" - select - p.activity as "activity: sqlx::types::Json", - a.* as person - from - activity p - inner join account a on p.actor=a.ap_id - where - ( - p.activity->'type' ? $1 - and - p.actor = $2 - and - p.activity_id > $3 - ) - or - p.created_at >= $4 - order by - p.created_at asc - limit 20 - "#, - "Follow", - actor, - id, - ts - ) - .fetch_all(&data.services.postgres) - .await? - .into_iter() - .map(|value| { - serde_json::from_value::(value.activity.0).map(|follow| { - ( - follow, - DbUser { - id: value.id, - description: value.description, - username: value.username, - ap_id: value.ap_id, - private_key: value.private_key, - public_key: value.public_key, - inbox: value.inbox, - outbox: value.outbox, - avatar_url: value.avatar_url, - local: value.local, - updated_at: value.updated_at, - created_at: value.created_at, - user_type: value.user_type, - }, - ) - }) - }) - .collect() - } else { - sqlx::query_as!( - Row, - r#" + let transaction: Result, _> = sqlx::query_as!( + FollowerRow, + r#" select p.activity as "activity: sqlx::types::Json", - a.* as person + a.* as person, + p.created_at as follow_date from activity p inner join account a on p.actor=a.ap_id @@ -190,74 +130,79 @@ impl Object for Follower { p.actor = $2 order by p.created_at asc - limit 20 + limit $3 "#, - "Follow", - actor - ) - .fetch_all(&data.services.postgres) - .await? - .into_iter() - .map(|value| { - serde_json::from_value::(value.activity.0).map(|follow| { - ( - follow, - DbUser { - id: value.id, - description: value.description, - username: value.username, - ap_id: value.ap_id, - private_key: value.private_key, - public_key: value.public_key, - inbox: value.inbox, - outbox: value.outbox, - avatar_url: value.avatar_url, - local: value.local, - updated_at: value.updated_at, - created_at: value.created_at, - user_type: value.user_type, - }, - ) - }) + "Follow", + actor, + PAGINATION_LIMIT + ) + .fetch_all(&data.services.postgres) + .await? + .into_iter() + .map(|value| { + serde_json::from_value::(value.activity.0).map(|follow| { + ( + follow, + DbUser { + id: value.id, + description: value.description, + username: value.username, + ap_id: value.ap_id, + private_key: value.private_key, + public_key: value.public_key, + inbox: value.inbox, + outbox: value.outbox, + avatar_url: value.avatar_url, + local: value.local, + updated_at: value.updated_at, + created_at: value.created_at, + user_type: value.user_type, + }, + value.follow_date, + ) }) - .collect() - }; + }) + .collect(); let transaction = transaction?; - trace!("{transaction:?}"); - let mut url = Url::parse(&format!("http://{domain}{}", self.user_id))?; + let mut url = Url::parse(&format!("{}://{domain}{}", data.protocol, &self.path))?; trace!(domain = domain, "setting path"); let mut users = Vec::with_capacity(transaction.len()); let mut timestamps = Vec::with_capacity(transaction.len()); + let mut activities = Vec::with_capacity(transaction.len()); - for (_, follower) in transaction.into_iter() { - let ts = follower.created_at.format(&Rfc3339)?; + for (activity, follower, follow_date) in transaction.into_iter() { + let ts = follow_date.format(&Rfc3339)?; let map = User::try_from(follower)?; users.push(map.into_json(data)); timestamps.push(ts); + activities.push(activity); } - let ordered_items: Vec<_> = futures_util::future::try_join_all(users).await?.into_iter().map(PartialPerson::from).collect(); + let ordered_items: Vec<_> = futures_util::future::try_join_all(users) + .await? + .into_iter() + .map(PartialPerson::from) + .collect(); - trace!("{ordered_items:?}"); + trace!(collection_items = ordered_items.len(), "follower count"); Ok(Self::Kind { - id: url.clone(), + id: url.clone().into(), kind: OrderedCollectionType::OrderedCollection, summary: Some("the followers".to_owned()), total_items: ordered_items.len(), - first: ordered_items.first().map(|user| FollowersPage { - part_of: url.clone(), + first: activities.first().map(|activity| FollowersPage { + part_of: url.clone().into(), id: { - let cursor = format!("{}|{}", user.id, ×tamps[0]); + let cursor = format!("{}|{}", activity.id, ×tamps[0]); let cursor = general_purpose::STANDARD.encode(cursor); url.set_query(Some(&format!("cursor={cursor}"))); - url + url.into() }, kind: OrderedCollectionPageType::OrderedCollectionPage, - summary: None, ordered_items: ordered_items.clone(), next: None, }), @@ -274,9 +219,22 @@ impl Object for Follower { async fn verify( json: &Self::Kind, expected_domain: &Url, - data: &Data, + _data: &Data, ) -> Result<(), Self::Error> { - todo!() + let domain = json + .id + .inner() + .domain() + .ok_or_else(|| anyhow!("missing domain"))?; + let expected = expected_domain + .domain() + .ok_or_else(|| anyhow!("missing domain"))?; + + if domain.eq(expected) { + Ok(()) + } else { + Err(anyhow!("domains do not match").into()) + } } #[doc = " Convert object from ActivityPub type to database type."] @@ -285,6 +243,14 @@ impl Object for Follower { #[doc = " should write the received object to database. Note that there is no distinction between"] #[doc = " create and update, so an `upsert` operation should be used."] async fn from_json(json: Self::Kind, data: &Data) -> Result { - todo!() + let id = json.id.inner(); + let mut paths = id.path_segments().ok_or_else(|| anyhow!("missing paths"))?; + paths.next(); + let username = paths.next().ok_or_else(|| anyhow!("missing username"))?; + + Ok(Self { + username: username.to_owned(), + path: format!("/users/{username}/followers"), + }) } } diff --git a/crates/sellershut/src/entity/user/followers/followers_page.rs b/crates/sellershut/src/entity/user/followers/followers_page.rs index e69de29..a725f37 100644 --- a/crates/sellershut/src/entity/user/followers/followers_page.rs +++ b/crates/sellershut/src/entity/user/followers/followers_page.rs @@ -0,0 +1,243 @@ +use std::{fmt::Display, str::FromStr}; + +use activitypub_federation::{ + config::Data, fetch::object_id::ObjectId, kinds::collection::OrderedCollectionPageType, + traits::Object, +}; +use anyhow::anyhow; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose}; +use serde::{Deserialize, Serialize}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use url::Url; + +use crate::{ + entity::user::{ + DbUser, PartialPerson, User, + followers::{DbFollower, FollowerRow}, + }, + error::AppError, + server::{PAGINATION_LIMIT, activities::follow::Follow}, + state::AppHandle, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DbFollowersPage { + pub follower: DbFollower, + pub cursor: String, +} + +pub struct FollowerPaginationTokens { + activity_id: String, + created_at: OffsetDateTime, +} + +impl Display for FollowerPaginationTokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cursor = format!("{}|{}", self.activity_id, &self.created_at); + let cursor = general_purpose::STANDARD.encode(cursor); + write!(f, "{cursor}") + } +} + +impl FromStr for FollowerPaginationTokens { + type Err = AppError; + + fn from_str(s: &str) -> Result { + let mut tokens = s.split("|"); + let id = Url::parse(tokens.next().ok_or_else(|| anyhow!("malformed cursor"))?)?; + let ts = tokens.next().ok_or_else(|| anyhow!("malformed cursor"))?; + let ts = OffsetDateTime::parse(ts, &Rfc3339)?; + Ok(Self { + activity_id: id.to_string(), + created_at: ts, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FollowersPage { + pub id: ObjectId, + #[serde(rename = "type")] + pub kind: OrderedCollectionPageType, + pub ordered_items: Vec, + pub next: Option>, + pub part_of: ObjectId, +} + +#[async_trait] +impl Object for DbFollowersPage { + #[doc = " App data type passed to handlers. Must be identical to"] + #[doc = " [crate::config::FederationConfigBuilder::app_data] type."] + type DataType = AppHandle; + + #[doc = " The type of protocol struct which gets sent over network to federate this database struct."] + type Kind = FollowersPage; + + #[doc = " Error type returned by handler methods"] + type Error = AppError; + + #[doc = " `id` field of the object"] + fn id(&self) -> &Url { + todo!() + } + + #[doc = " Try to read the object with given `id` from local database."] + #[doc = ""] + #[doc = " Should return `Ok(None)` if not found."] + async fn read_from_id( + object_id: Url, + data: &Data, + ) -> Result, Self::Error> { + todo!() + } + + #[doc = " Convert database type to Activitypub type."] + #[doc = ""] + #[doc = " Called when a local object gets fetched by another instance over HTTP, or when an object"] + #[doc = " gets sent in an activity."] + async fn into_json(self, data: &Data) -> Result { + let domain = data.domain(); + + let actor = Url::parse(&format!( + "{}://{domain}/users/{}", + data.protocol, self.follower.username + ))? + .to_string(); + + let result = general_purpose::STANDARD.decode(&self.cursor)?; + let tokens = String::from_utf8_lossy(result.as_slice()); + let pagination = FollowerPaginationTokens::from_str(tokens.as_ref())?; + + let transaction: Result, _> = sqlx::query_as!( + FollowerRow, + r#" + select + p.activity as "activity: sqlx::types::Json", + a.* as person, + p.created_at as follow_date + from + activity p + inner join account a on p.actor=a.ap_id + where + ( + p.activity->'type' ? $1 + and + p.actor = $2 + and + p.activity_id > $3 + ) + or + p.created_at >= $4 + order by + p.created_at asc + limit $5 + "#, + "Follow", + actor, + pagination.activity_id.to_string(), + pagination.created_at, + PAGINATION_LIMIT + ) + .fetch_all(&data.services.postgres) + .await? + .into_iter() + .map(|value| { + serde_json::from_value::(value.activity.0).map(|follow| { + ( + follow, + DbUser { + id: value.id, + description: value.description, + username: value.username, + ap_id: value.ap_id, + private_key: value.private_key, + public_key: value.public_key, + inbox: value.inbox, + outbox: value.outbox, + avatar_url: value.avatar_url, + local: value.local, + updated_at: value.updated_at, + created_at: value.created_at, + user_type: value.user_type, + }, + value.follow_date, + ) + }) + }) + .collect(); + + let transaction = transaction?; + + let mut url = Url::parse(&format!( + "{}://{domain}{}", + data.protocol, self.follower.path + ))?; + + let mut users = Vec::with_capacity(transaction.len()); + let mut timestamps = Vec::with_capacity(transaction.len()); + let mut activities = Vec::with_capacity(transaction.len()); + + for (activity, follower, follow_date) in transaction.into_iter() { + let ts = follow_date.format(&Rfc3339)?; + let map = User::try_from(follower)?; + users.push(map.into_json(data)); + timestamps.push(ts); + activities.push(activity); + } + let ordered_items: Vec<_> = futures_util::future::try_join_all(users) + .await? + .into_iter() + .map(PartialPerson::from) + .collect(); + + let activity_id = activities + .first() + .map(|v| v.id.to_string()) + .unwrap_or_default(); + let ts = timestamps + .first() + .map(ToOwned::to_owned) + .unwrap_or_default(); + + Ok(Self::Kind { + part_of: url.clone().into(), + id: { + let t = FollowerPaginationTokens { + activity_id, + created_at: OffsetDateTime::parse(&ts, &Rfc3339)?, + }; + url.set_query(Some(&format!("cursor={t}"))); + url.into() + }, + kind: OrderedCollectionPageType::OrderedCollectionPage, + ordered_items: ordered_items.clone(), + next: None, + }) + } + + #[doc = " Verifies that the received object is valid."] + #[doc = ""] + #[doc = " You should check here that the domain of id matches `expected_domain`. Additionally you"] + #[doc = " should perform any application specific checks."] + #[doc = ""] + #[doc = " It is necessary to use a separate method for this, because it might be used for activities"] + #[doc = " like `Delete/Note`, which shouldn\'t perform any database write for the inner `Note`."] + async fn verify( + json: &Self::Kind, + expected_domain: &Url, + data: &Data, + ) -> Result<(), Self::Error> { + todo!() + } + + #[doc = " Convert object from ActivityPub type to database type."] + #[doc = ""] + #[doc = " Called when an object is received from HTTP fetch or as part of an activity. This method"] + #[doc = " should write the received object to database. Note that there is no distinction between"] + #[doc = " create and update, so an `upsert` operation should be used."] + async fn from_json(json: Self::Kind, data: &Data) -> Result { + todo!() + } +} diff --git a/crates/sellershut/src/server.rs b/crates/sellershut/src/server.rs index 32bf036..18fb84f 100644 --- a/crates/sellershut/src/server.rs +++ b/crates/sellershut/src/server.rs @@ -1,7 +1,6 @@ use activitypub_federation::config::{FederationConfig, FederationMiddleware}; use axum::{Router, routing::get}; use nanoid::nanoid; -use stack_up::Environment; use tower_http::trace::TraceLayer; use url::Url; @@ -16,15 +15,11 @@ const ALPHABET: [char; 36] = [ 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '-', ]; -pub fn generate_object_id(domain: &str, env: Environment) -> Result { +pub const PAGINATION_LIMIT: i64 = 20; + +pub fn generate_object_id(domain: &str, protocol: &str) -> Result { let id = nanoid!(21, &ALPHABET); - Ok(Url::parse(&format!( - "{}://{domain}/objects/{id}", - match env { - Environment::Development => "http", - Environment::Production => "https", - }, - ))?) + Ok(Url::parse(&format!("{protocol}://{domain}/objects/{id}"))?) } pub fn router(state: FederationConfig) -> Router { diff --git a/crates/sellershut/src/server/activities/follow.rs b/crates/sellershut/src/server/activities/follow.rs index 6a8fca5..bfabbfd 100644 --- a/crates/sellershut/src/server/activities/follow.rs +++ b/crates/sellershut/src/server/activities/follow.rs @@ -97,7 +97,7 @@ impl Activity for Follow { } let follower = self.actor.dereference(data).await?; - let id = generate_object_id(data.domain(), data.environment)?; + let id = generate_object_id(data.domain(), &data.protocol)?; let local_user = self.object.dereference(data).await?; let accept = Accept::new(self.object.clone(), self.clone(), id.clone()); diff --git a/crates/sellershut/src/server/routes/users/followers.rs b/crates/sellershut/src/server/routes/users/followers.rs index ecc5bf0..ae74929 100644 --- a/crates/sellershut/src/server/routes/users/followers.rs +++ b/crates/sellershut/src/server/routes/users/followers.rs @@ -8,13 +8,16 @@ use axum::{ response::IntoResponse, }; use serde::Deserialize; -use tracing::trace; -use crate::{entity::user::followers::Follower, error::AppError, state::AppHandle}; +use crate::{ + entity::user::followers::{DbFollower, followers_page::DbFollowersPage}, + error::AppError, + state::AppHandle, +}; #[derive(Deserialize)] pub struct Cursor { - pub cursor: Option, + pub cursor: Option, } #[debug_handler] @@ -24,15 +27,245 @@ pub async fn http_get_followers( uri: Uri, data: Data, ) -> Result { - trace!(uri = uri.path(), "getting"); - let follower = Follower { - user_id: uri.path().to_string(), - cursor: cursor.cursor, + let follower = DbFollower { + username: name, + path: uri.path().to_string(), + }; + if let Some(cursor) = cursor.cursor { + let followers = DbFollowersPage { follower, cursor }; + let json_user = followers.into_json(&data).await?; + if json_user.ordered_items.is_empty() { + Ok((StatusCode::NO_CONTENT, "").into_response()) + } else { + Ok(( + StatusCode::OK, + FederationJson(WithContext::new_default(json_user)), + ) + .into_response()) + } + } else { + let json_user = follower.into_json(&data).await?; + Ok(( + StatusCode::OK, + FederationJson(WithContext::new_default(json_user)), + ) + .into_response()) + } +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use serde::{Deserialize, Serialize}; + use serde_json::Value; + use sqlx::PgPool; + use stack_up::Services; + use tower::ServiceExt; + use url::Url; + + use crate::{ + server::{self, test_config}, + state::AppState, }; - let json_user = follower.into_json(&data).await?; - Ok(( - StatusCode::OK, - FederationJson(WithContext::new_default(json_user)), - ) - .into_response()) + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Root { + #[serde(rename = "@context")] + pub context: String, + pub id: String, + #[serde(rename = "type")] + pub type_field: String, + pub summary: String, + pub total_items: i64, + pub first: First, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct First { + pub id: String, + #[serde(rename = "type")] + pub type_field: String, + pub ordered_items: Vec, + pub next: Value, + pub part_of: String, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct OrderedItem { + #[serde(rename = "type")] + pub type_field: String, + pub preferred_username: String, + pub id: String, + } + + #[sqlx::test] + async fn followers_collection(pool: PgPool) { + let services = Services { postgres: pool }; + let config = test_config(); + + let hut_config: crate::cnfg::LocalConfig = + serde_json::from_value(config.misc.clone()).unwrap(); + let state = AppState::create(services, &config).await.unwrap(); + + let id = format!( + "http://localhost:{}/activity/follow/1", + config.application.port + ); + let actor = format!("http://localhost/users/{}", hut_config.instance_name); + + let app = server::router(state); + + let body = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": id, + "type": "Follow", + "actor": actor, + "object": actor, + }); + let body = serde_json::to_vec(&body).unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .header("Content-Type", "application/activity+json") + .uri(format!("/users/{}/inbox", hut_config.instance_name)) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .clone() + .oneshot( + Request::builder() + .header("Content-Type", "application/activity+json") + .uri(format!("/users/{}/followers", hut_config.instance_name)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test] + async fn followers_collection_empty(pool: PgPool) { + let services = Services { postgres: pool }; + let config = test_config(); + + let hut_config: crate::cnfg::LocalConfig = + serde_json::from_value(config.misc.clone()).unwrap(); + let state = AppState::create(services, &config).await.unwrap(); + + let app = server::router(state); + + let response = app + .clone() + .oneshot( + Request::builder() + .header("Content-Type", "application/activity+json") + .uri(format!("/users/{}/followers", hut_config.instance_name)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[sqlx::test] + async fn followers_page(pool: PgPool) { + let services = Services { postgres: pool }; + let config = test_config(); + + let hut_config: crate::cnfg::LocalConfig = + serde_json::from_value(config.misc.clone()).unwrap(); + let state = AppState::create(services, &config).await.unwrap(); + + let id = format!( + "http://localhost:{}/activity/follow/1", + config.application.port + ); + let actor = format!("http://localhost/users/{}", hut_config.instance_name); + + let app = server::router(state); + + let body = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": id, + "type": "Follow", + "actor": actor, + "object": actor, + }); + let body = serde_json::to_vec(&body).unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .header("Content-Type", "application/activity+json") + .uri(format!("/users/{}/inbox", hut_config.instance_name)) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .clone() + .oneshot( + Request::builder() + .header("Content-Type", "application/activity+json") + .uri(format!("/users/{}/followers", hut_config.instance_name)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let status = response.status(); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + + let json: Root = serde_json::from_slice(&body_bytes).unwrap(); + + let url = Url::parse(&json.first.id).unwrap(); + dbg!(&url.query()); + // cursor=abc + let cursor = url.query().unwrap().replace("cursor=", ""); + + assert_eq!(status, StatusCode::OK); + + let req = Request::builder() + .header("Content-Type", "application/activity+json") + .uri(format!( + "/users/{}/followers?cursor={cursor}", + hut_config.instance_name + )) + .body(Body::empty()) + .unwrap(); + + dbg!(&req); + let response = app.oneshot(req).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/crates/sellershut/src/state.rs b/crates/sellershut/src/state.rs index c524152..3ee3248 100644 --- a/crates/sellershut/src/state.rs +++ b/crates/sellershut/src/state.rs @@ -19,6 +19,7 @@ impl Deref for AppHandle { pub struct AppState { pub services: Services, pub environment: Environment, + pub protocol: Arc, } impl AppState { @@ -42,9 +43,13 @@ impl AppState { .app_data(AppHandle(Arc::new(Self { services, environment: configuration.application.env, + protocol: match configuration.application.env { + Environment::Development => "http", + Environment::Production => "https", + } + .into(), }))) // .url_verifier(Box::new(MyUrlVerifier())) - // TODO: could change this to env variable? .debug(configuration.application.env == Environment::Development) .build() .await?; -- cgit v1.2.3