summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorrtkay123 <dev@kanjala.com>2025-07-22 17:06:46 +0200
committerrtkay123 <dev@kanjala.com>2025-07-22 17:06:46 +0200
commit23eddb4593540f0103fab66cf1c6ef4748efb108 (patch)
treebb37e9414652491d2d55b9e879962c535e168482
parente0c23477bd07c86522df6e972fbdb7a70d647431 (diff)
downloadsellershut-23eddb4593540f0103fab66cf1c6ef4748efb108.tar.bz2
sellershut-23eddb4593540f0103fab66cf1c6ef4748efb108.zip
feat: follower ordered collections
-rw-r--r--contrib/bruno/users/followers.bru6
-rw-r--r--crates/sellershut/src/entity/user.rs6
-rw-r--r--crates/sellershut/src/entity/user/followers.rs256
-rw-r--r--crates/sellershut/src/entity/user/followers/followers_page.rs243
-rw-r--r--crates/sellershut/src/server.rs13
-rw-r--r--crates/sellershut/src/server/activities/follow.rs2
-rw-r--r--crates/sellershut/src/server/routes/users/followers.rs259
-rw-r--r--crates/sellershut/src/state.rs7
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<Person> 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<String>,
+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<DbFollower>,
#[serde(rename = "type")]
kind: OrderedCollectionType,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -36,21 +42,8 @@ pub struct FollowersCollection {
first: Option<FollowersPage>,
}
-#[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<String>,
- ordered_items: Vec<PartialPerson>,
- next: Option<Url>,
- part_of: Url,
-}
-
#[derive(Serialize)]
-struct Row {
+pub struct FollowerRow {
activity: sqlx::types::Json<serde_json::Value>,
id: String,
description: Option<String>,
@@ -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<Self::DataType>,
+ _data: &Data<Self::DataType>,
) -> Result<Option<Self>, 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<Self::DataType>) -> Result<Self::Kind, Self::Error> {
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<Vec<_>, _> = 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<serde_json::Value>",
- 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::<Follow>(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<Vec<_>, _> = sqlx::query_as!(
+ FollowerRow,
+ r#"
select
p.activity as "activity: sqlx::types::Json<serde_json::Value>",
- 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::<Follow>(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::<Follow>(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, &timestamps[0]);
+ let cursor = format!("{}|{}", activity.id, &timestamps[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<Self::DataType>,
+ _data: &Data<Self::DataType>,
) -> 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<Self::DataType>) -> Result<Self, Self::Error> {
- 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<Self, Self::Err> {
+ 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<DbFollowersPage>,
+ #[serde(rename = "type")]
+ pub kind: OrderedCollectionPageType,
+ pub ordered_items: Vec<PartialPerson>,
+ pub next: Option<ObjectId<DbFollowersPage>>,
+ pub part_of: ObjectId<DbFollower>,
+}
+
+#[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<Self::DataType>,
+ ) -> Result<Option<Self>, 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<Self::DataType>) -> Result<Self::Kind, Self::Error> {
+ 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<Vec<_>, _> = sqlx::query_as!(
+ FollowerRow,
+ r#"
+ select
+ p.activity as "activity: sqlx::types::Json<serde_json::Value>",
+ 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::<Follow>(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<Self::DataType>,
+ ) -> 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<Self::DataType>) -> Result<Self, Self::Error> {
+ 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<Url, AppError> {
+pub const PAGINATION_LIMIT: i64 = 20;
+
+pub fn generate_object_id(domain: &str, protocol: &str) -> Result<Url, AppError> {
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<AppHandle>) -> 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<String>,
+ pub cursor: Option<String>,
}
#[debug_handler]
@@ -24,15 +27,245 @@ pub async fn http_get_followers(
uri: Uri,
data: Data<AppHandle>,
) -> Result<impl IntoResponse, AppError> {
- 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<OrderedItem>,
+ 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<str>,
}
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?;