Compare commits
9 Commits
ed6996e350
...
8ef7c93970
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef7c93970 | |||
| dc3afeca26 | |||
| f3c3637ade | |||
| 00b369c6ad | |||
| 8c931c9b98 | |||
| 1ddb6a3954 | |||
| 70fc4fbcd0 | |||
| cbfaeb95ac | |||
| 2e64e196b5 |
@@ -1394,6 +1394,15 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
public_key: actor.public_key_pem.clone(),
|
public_key: actor.public_key_pem.clone(),
|
||||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||||
last_fetched_at: actor.last_refreshed_at,
|
last_fetched_at: actor.last_refreshed_at,
|
||||||
|
bio: actor.bio.clone(),
|
||||||
|
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||||
|
also_known_as: actor.also_known_as.clone(),
|
||||||
|
outbox_url: Some(actor.outbox_url.to_string()),
|
||||||
|
attachment: actor
|
||||||
|
.attachment
|
||||||
|
.iter()
|
||||||
|
.map(|f| (f.name.clone(), f.value.clone()))
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,6 +1520,65 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
serde_json::to_string(&obj)
|
serde_json::to_string(&obj)
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_outbox_page(
|
||||||
|
&self,
|
||||||
|
outbox_url: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Vec<domain::models::remote_note::RemoteNote>, domain::errors::DomainError> {
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
|
let url = format!("{}?page={}", outbox_url, page);
|
||||||
|
let resp: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let empty = vec![];
|
||||||
|
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
|
|
||||||
|
let notes = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
// Items are Create activities wrapping a Note, or Notes directly
|
||||||
|
let note = if item["type"].as_str() == Some("Create") {
|
||||||
|
&item["object"]
|
||||||
|
} else if item["type"].as_str() == Some("Note") {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only public notes
|
||||||
|
let to = note["to"].as_array()?;
|
||||||
|
let is_public = to
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
|
||||||
|
if !is_public {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
||||||
|
.ok()?
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
|
||||||
|
Some(domain::models::remote_note::RemoteNote {
|
||||||
|
ap_id: note["id"].as_str()?.to_string(),
|
||||||
|
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||||
|
published,
|
||||||
|
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||||
|
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(notes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ pub enum EventPayload {
|
|||||||
UserRegistered {
|
UserRegistered {
|
||||||
user_id: String,
|
user_id: String,
|
||||||
},
|
},
|
||||||
|
FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: String,
|
||||||
|
outbox_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -88,6 +92,7 @@ impl EventPayload {
|
|||||||
Self::UserBlocked { .. } => "users.blocked",
|
Self::UserBlocked { .. } => "users.blocked",
|
||||||
Self::UserUnblocked { .. } => "users.unblocked",
|
Self::UserUnblocked { .. } => "users.unblocked",
|
||||||
Self::UserRegistered { .. } => "users.registered",
|
Self::UserRegistered { .. } => "users.registered",
|
||||||
|
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,13 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url,
|
||||||
|
outbox_url,
|
||||||
|
} => Self::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: actor_ap_url.clone(),
|
||||||
|
outbox_url: outbox_url.clone(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +327,13 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
},
|
},
|
||||||
|
EventPayload::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url,
|
||||||
|
outbox_url,
|
||||||
|
} => DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url,
|
||||||
|
outbox_url,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,6 +408,17 @@ mod tests {
|
|||||||
blocker_id: "a".into(),
|
blocker_id: "a".into(),
|
||||||
blocked_id: "b".into(),
|
blocked_id: "b".into(),
|
||||||
},
|
},
|
||||||
|
EventPayload::UserUnblocked {
|
||||||
|
blocker_id: "a".into(),
|
||||||
|
blocked_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::UserRegistered {
|
||||||
|
user_id: "a".into(),
|
||||||
|
},
|
||||||
|
EventPayload::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
||||||
|
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||||
subjects.sort();
|
subjects.sort();
|
||||||
|
|||||||
@@ -45,6 +45,6 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
|||||||
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||||
).bind(url).fetch_optional(&self.pool).await
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at }))
|
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at, bio: None, banner_url: None, also_known_as: None, outbox_url: None, attachment: vec![] }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ pub struct CreatedApiKeyResponse {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProfileField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RemoteActorResponse {
|
pub struct RemoteActorResponse {
|
||||||
@@ -95,4 +102,9 @@ pub struct RemoteActorResponse {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub outbox_url: Option<String>,
|
||||||
|
pub attachment: Vec<ProfileField>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ uuid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
tracing = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
ports::{OutboundFederationPort, ThoughtRepository, UserRepository},
|
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository},
|
||||||
value_objects::ThoughtId,
|
value_objects::ThoughtId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -12,6 +12,8 @@ pub struct FederationEventService {
|
|||||||
pub users: Arc<dyn UserRepository>,
|
pub users: Arc<dyn UserRepository>,
|
||||||
pub ap: Arc<dyn OutboundFederationPort>,
|
pub ap: Arc<dyn OutboundFederationPort>,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
|
||||||
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationEventService {
|
impl FederationEventService {
|
||||||
@@ -112,6 +114,49 @@ impl FederationEventService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url,
|
||||||
|
outbox_url,
|
||||||
|
} => {
|
||||||
|
let notes = match self
|
||||||
|
.federation_action
|
||||||
|
.fetch_outbox_page(outbox_url, 1)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let actor_url = url::Url::parse(actor_ap_url)
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
||||||
|
|
||||||
|
for note in notes {
|
||||||
|
let ap_id = match url::Url::parse(¬e.ap_id) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let _ = self
|
||||||
|
.ap_repo
|
||||||
|
.accept_note(
|
||||||
|
&ap_id,
|
||||||
|
&author_id,
|
||||||
|
¬e.content,
|
||||||
|
note.published,
|
||||||
|
note.sensitive,
|
||||||
|
note.content_warning,
|
||||||
|
"public",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +171,7 @@ mod tests {
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
models::user::User,
|
models::user::User,
|
||||||
ports::OutboundFederationPort,
|
ports::{ActivityPubRepository, OutboundFederationPort},
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
@@ -208,6 +253,8 @@ mod tests {
|
|||||||
users: Arc::new(store.clone()),
|
users: Arc::new(store.clone()),
|
||||||
ap: spy,
|
ap: spy,
|
||||||
base_url: "https://example.com".to_string(),
|
base_url: "https://example.com".to_string(),
|
||||||
|
federation_action: Arc::new(store.clone()),
|
||||||
|
ap_repo: Arc::new(store.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,4 +578,18 @@ mod tests {
|
|||||||
|
|
||||||
assert!(spy.updated.lock().unwrap().is_empty());
|
assert!(spy.updated.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
||||||
|
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// TestStore.fetch_outbox_page returns Ok(vec![]) — no notes, no error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||||
events: event_publisher,
|
events: event_publisher,
|
||||||
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||||
|
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
};
|
};
|
||||||
|
|
||||||
Infrastructure { state, ap_service }
|
Infrastructure { state, ap_service }
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ pub enum DomainEvent {
|
|||||||
UserRegistered {
|
UserRegistered {
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
},
|
},
|
||||||
|
FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: String,
|
||||||
|
outbox_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventEnvelope {
|
pub struct EventEnvelope {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod api_key;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod remote_actor;
|
pub mod remote_actor;
|
||||||
|
pub mod remote_note;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ pub struct RemoteActor {
|
|||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub last_fetched_at: DateTime<Utc>,
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub outbox_url: Option<String>,
|
||||||
|
pub attachment: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|||||||
10
crates/domain/src/models/remote_note.rs
Normal file
10
crates/domain/src/models/remote_note.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteNote {
|
||||||
|
pub ap_id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub published: DateTime<Utc>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
}
|
||||||
@@ -209,6 +209,11 @@ pub trait FederationActionPort: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
) -> Result<String, DomainError>;
|
) -> Result<String, DomainError>;
|
||||||
|
async fn fetch_outbox_page(
|
||||||
|
&self,
|
||||||
|
outbox_url: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -567,6 +567,14 @@ impl FederationActionPort for TestStore {
|
|||||||
) -> Result<String, DomainError> {
|
) -> Result<String, DomainError> {
|
||||||
Err(DomainError::NotFound)
|
Err(DomainError::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_outbox_page(
|
||||||
|
&self,
|
||||||
|
_outbox_url: &str,
|
||||||
|
_page: u32,
|
||||||
|
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -833,6 +841,16 @@ mod federation_port_tests {
|
|||||||
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
||||||
assert!(matches!(err, DomainError::NotFound));
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_store_fetch_outbox_returns_empty() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let notes = store
|
||||||
|
.fetch_outbox_page("https://example.com/outbox", 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(notes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
136
crates/presentation/src/handlers/federation_actors.rs
Normal file
136
crates/presentation/src/handlers/federation_actors.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use crate::{
|
||||||
|
errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use api_types::requests::PaginationQuery;
|
||||||
|
use application::use_cases::feed::get_user_feed;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use domain::{events::DomainEvent, models::feed::PageParams};
|
||||||
|
|
||||||
|
pub async fn remote_actor_posts_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(handle): Path<String>,
|
||||||
|
Query(q): Query<PaginationQuery>,
|
||||||
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let actor = s.federation.lookup_actor(&handle).await?;
|
||||||
|
|
||||||
|
let ap_url = url::Url::parse(&actor.url).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
// Get or create interned local UserId for this remote actor
|
||||||
|
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||||
|
Some(id) => id,
|
||||||
|
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return cached posts from DB
|
||||||
|
let page = PageParams {
|
||||||
|
page: q.page(),
|
||||||
|
per_page: q.per_page(),
|
||||||
|
};
|
||||||
|
let result = get_user_feed(&*s.feed, &author_id, page, viewer.as_ref()).await?;
|
||||||
|
|
||||||
|
// Trigger background outbox fetch (fire and forget)
|
||||||
|
if let Some(outbox_url) = &actor.outbox_url {
|
||||||
|
let _ = s
|
||||||
|
.events
|
||||||
|
.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: actor.url.clone(),
|
||||||
|
outbox_url: outbox_url.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"total": result.total,
|
||||||
|
"page": result.page,
|
||||||
|
"per_page": result.per_page,
|
||||||
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{body::Body, http::Request, routing::get, Router};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct NoOpAuth;
|
||||||
|
impl AuthService for NoOpAuth {
|
||||||
|
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
Err(DomainError::Internal("noop".into()))
|
||||||
|
}
|
||||||
|
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||||
|
Err(DomainError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoOpHasher;
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for NoOpHasher {
|
||||||
|
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
Err(DomainError::Internal("noop".into()))
|
||||||
|
}
|
||||||
|
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_state() -> crate::state::AppState {
|
||||||
|
let store = Arc::new(TestStore::default());
|
||||||
|
crate::state::AppState {
|
||||||
|
users: store.clone(),
|
||||||
|
thoughts: store.clone(),
|
||||||
|
likes: store.clone(),
|
||||||
|
boosts: store.clone(),
|
||||||
|
follows: store.clone(),
|
||||||
|
blocks: store.clone(),
|
||||||
|
tags: store.clone(),
|
||||||
|
api_keys: store.clone(),
|
||||||
|
top_friends: store.clone(),
|
||||||
|
notifications: store.clone(),
|
||||||
|
remote_actors: store.clone(),
|
||||||
|
feed: store.clone(),
|
||||||
|
search: store.clone(),
|
||||||
|
auth: Arc::new(NoOpAuth),
|
||||||
|
hasher: Arc::new(NoOpHasher),
|
||||||
|
events: store.clone(),
|
||||||
|
federation: store.clone(),
|
||||||
|
ap_repo: store.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/federation/actors/{handle}/posts",
|
||||||
|
get(remote_actor_posts_handler),
|
||||||
|
)
|
||||||
|
.with_state(make_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_actor_returns_404() {
|
||||||
|
let resp = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/federation/actors/%40alice%40example.com/posts")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ use axum::{
|
|||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||||
ThoughtResponse {
|
ThoughtResponse {
|
||||||
id: e.thought.id.as_uuid(),
|
id: e.thought.id.as_uuid(),
|
||||||
content: e.thought.content.as_str().to_string(),
|
content: e.thought.content.as_str().to_string(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod federation_actors;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{PaginationQuery, UpdateProfileRequest},
|
requests::{PaginationQuery, UpdateProfileRequest},
|
||||||
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
|
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::feed::list_users;
|
use application::use_cases::feed::list_users;
|
||||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||||
@@ -200,6 +200,15 @@ pub async fn lookup_handler(
|
|||||||
display_name: actor.display_name,
|
display_name: actor.display_name,
|
||||||
avatar_url: actor.avatar_url,
|
avatar_url: actor.avatar_url,
|
||||||
url: actor.url,
|
url: actor.url,
|
||||||
|
bio: actor.bio,
|
||||||
|
banner_url: actor.banner_url,
|
||||||
|
also_known_as: actor.also_known_as,
|
||||||
|
outbox_url: actor.outbox_url,
|
||||||
|
attachment: actor
|
||||||
|
.attachment
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| ProfileField { name, value })
|
||||||
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +272,7 @@ mod tests {
|
|||||||
hasher: Arc::new(NoOpHasher),
|
hasher: Arc::new(NoOpHasher),
|
||||||
events: store.clone(),
|
events: store.clone(),
|
||||||
federation: store.clone(),
|
federation: store.clone(),
|
||||||
|
ap_repo: store.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/feed", get(feed::home_feed))
|
.route("/feed", get(feed::home_feed))
|
||||||
.route("/feed/public", get(feed::public_feed))
|
.route("/feed/public", get(feed::public_feed))
|
||||||
.route("/search", get(feed::search_handler))
|
.route("/search", get(feed::search_handler))
|
||||||
|
.route(
|
||||||
|
"/federation/actors/{handle}/posts",
|
||||||
|
get(federation_actors::remote_actor_posts_handler),
|
||||||
|
)
|
||||||
.route("/tags/popular", get(feed::get_popular_tags))
|
.route("/tags/popular", get(feed::get_popular_tags))
|
||||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||||
// notifications
|
// notifications
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ pub struct AppState {
|
|||||||
pub hasher: Arc<dyn PasswordHasher>,
|
pub hasher: Arc<dyn PasswordHasher>,
|
||||||
pub events: Arc<dyn EventPublisher>,
|
pub events: Arc<dyn EventPublisher>,
|
||||||
pub federation: Arc<dyn FederationActionPort>,
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::ThoughtsObjectHandler;
|
||||||
use activitypub_base::ActivityPubService;
|
use activitypub_base::ActivityPubService;
|
||||||
use application::services::{FederationEventService, NotificationEventService};
|
use application::services::{FederationEventService, NotificationEventService};
|
||||||
|
use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ pub async fn build(
|
|||||||
));
|
));
|
||||||
|
|
||||||
// ActivityPub service (for federation fan-out)
|
// ActivityPub service (for federation fan-out)
|
||||||
let ap_service: Arc<dyn domain::ports::OutboundFederationPort> = Arc::new(
|
let ap_service = Arc::new(
|
||||||
ActivityPubService::new(
|
ActivityPubService::new(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
Arc::new(PostgresApUserRepository::new(
|
Arc::new(PostgresApUserRepository::new(
|
||||||
@@ -54,6 +55,10 @@ pub async fn build(
|
|||||||
.await
|
.await
|
||||||
.expect("ActivityPubService build failed"),
|
.expect("ActivityPubService build failed"),
|
||||||
);
|
);
|
||||||
|
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||||
|
let ap_federation = ap_service.clone() as Arc<dyn FederationActionPort>;
|
||||||
|
let ap_repo_worker =
|
||||||
|
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
let notification_svc = Arc::new(NotificationEventService {
|
let notification_svc = Arc::new(NotificationEventService {
|
||||||
@@ -63,8 +68,10 @@ pub async fn build(
|
|||||||
let federation_svc = Arc::new(FederationEventService {
|
let federation_svc = Arc::new(FederationEventService {
|
||||||
thoughts,
|
thoughts,
|
||||||
users,
|
users,
|
||||||
ap: ap_service,
|
ap: ap_outbound,
|
||||||
base_url: base_url.to_string(),
|
base_url: base_url.to_string(),
|
||||||
|
federation_action: ap_federation,
|
||||||
|
ap_repo: ap_repo_worker,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Thin handlers
|
// Thin handlers
|
||||||
|
|||||||
1288
docs/superpowers/plans/2026-05-14-remote-actor-profile.md
Normal file
1288
docs/superpowers/plans/2026-05-14-remote-actor-profile.md
Normal file
File diff suppressed because it is too large
Load Diff
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Remote Actor Profile Design
|
||||||
|
|
||||||
|
Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User navigates to `/users/@gabrielkaszewski@mastodon.social`
|
||||||
|
2. Frontend detects `@user@domain` format, calls in parallel:
|
||||||
|
- `GET /users/lookup?handle=@user@instance` → enriched profile metadata
|
||||||
|
- `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit)
|
||||||
|
3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget
|
||||||
|
4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note`
|
||||||
|
5. On next visit/refresh posts are populated
|
||||||
|
|
||||||
|
## Domain Changes
|
||||||
|
|
||||||
|
### Extend `domain/src/models/remote_actor.rs`
|
||||||
|
|
||||||
|
Add fields:
|
||||||
|
```rust
|
||||||
|
pub struct RemoteActor {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub inbox_url: String,
|
||||||
|
pub shared_inbox_url: Option<String>,
|
||||||
|
pub public_key: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
|
// new:
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub outbox_url: Option<String>,
|
||||||
|
pub attachment: Vec<(String, String)>, // (name, value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New `domain/src/models/remote_note.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RemoteNote {
|
||||||
|
pub ap_id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub published: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: String,
|
||||||
|
outbox_url: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New `FederationActionPort` method (`domain/src/ports.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn fetch_outbox_page(
|
||||||
|
&self,
|
||||||
|
outbox_url: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Vec<RemoteNote>, DomainError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestStore` stub returns `Ok(vec![])`.
|
||||||
|
|
||||||
|
## activitypub-base Implementation
|
||||||
|
|
||||||
|
### `lookup_actor` — populate new `RemoteActor` fields
|
||||||
|
|
||||||
|
Map from `DbActor`:
|
||||||
|
```rust
|
||||||
|
bio: actor.bio.clone(),
|
||||||
|
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||||
|
also_known_as: actor.also_known_as.clone(),
|
||||||
|
outbox_url: Some(actor.outbox_url.to_string()),
|
||||||
|
attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(),
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fetch_outbox_page` impl on `ActivityPubService`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
|
||||||
|
let url = format!("{}?page={}", outbox_url, page);
|
||||||
|
let resp: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json().await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
|
||||||
|
Ok(items.iter().filter_map(|item| {
|
||||||
|
// Items are Create activities or Notes directly
|
||||||
|
let note = if item["type"].as_str() == Some("Create") {
|
||||||
|
&item["object"]
|
||||||
|
} else if item["type"].as_str() == Some("Note") {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
// Only public notes
|
||||||
|
let to = note["to"].as_array()?;
|
||||||
|
let is_public = to.iter().any(|t| {
|
||||||
|
t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
|
||||||
|
});
|
||||||
|
if !is_public { return None; }
|
||||||
|
Some(RemoteNote {
|
||||||
|
ap_id: note["id"].as_str()?.to_string(),
|
||||||
|
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||||
|
published: chrono::DateTime::parse_from_rfc3339(
|
||||||
|
note["published"].as_str()?
|
||||||
|
).ok()?.with_timezone(&chrono::Utc),
|
||||||
|
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||||
|
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## AppState + Bootstrap
|
||||||
|
|
||||||
|
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
|
||||||
|
|
||||||
|
Wire in `bootstrap/src/factory.rs`:
|
||||||
|
```rust
|
||||||
|
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
|
```
|
||||||
|
|
||||||
|
## event-payload
|
||||||
|
|
||||||
|
Add to `EventPayload` enum:
|
||||||
|
```rust
|
||||||
|
FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: String,
|
||||||
|
outbox_url: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test.
|
||||||
|
|
||||||
|
## REST Endpoint
|
||||||
|
|
||||||
|
**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn remote_actor_posts_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(handle): Path<String>,
|
||||||
|
Query(q): Query<PaginationQuery>,
|
||||||
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let actor = s.federation.lookup_actor(&handle).await?;
|
||||||
|
let ap_url = url::Url::parse(&actor.url)
|
||||||
|
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
// Get or create interned local UserId for this remote actor
|
||||||
|
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||||
|
Some(id) => id,
|
||||||
|
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return cached posts
|
||||||
|
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||||
|
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
|
||||||
|
|
||||||
|
// Trigger background fetch (fire and forget)
|
||||||
|
if let Some(outbox_url) = &actor.outbox_url {
|
||||||
|
let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||||
|
actor_ap_url: actor.url.clone(),
|
||||||
|
outbox_url: outbox_url.clone(),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"total": result.total,
|
||||||
|
"page": result.page,
|
||||||
|
"per_page": result.per_page,
|
||||||
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
|
||||||
|
|
||||||
|
Add `pub mod federation_actors;` to `handlers/mod.rs`.
|
||||||
|
|
||||||
|
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
|
||||||
|
|
||||||
|
## api-types
|
||||||
|
|
||||||
|
Extend `RemoteActorResponse`:
|
||||||
|
```rust
|
||||||
|
pub struct RemoteActorResponse {
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub url: String,
|
||||||
|
// new:
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub outbox_url: Option<String>,
|
||||||
|
pub attachment: Vec<ProfileField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfileField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `lookup_handler` in `users.rs` to populate all new fields.
|
||||||
|
|
||||||
|
## Worker
|
||||||
|
|
||||||
|
### `FederationEventService` new deps
|
||||||
|
|
||||||
|
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
|
||||||
|
let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); }
|
||||||
|
};
|
||||||
|
let actor_url = url::Url::parse(actor_ap_url)
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
||||||
|
for note in notes {
|
||||||
|
let ap_id = match url::Url::parse(¬e.ap_id) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
// accept_note is idempotent — ignore duplicate errors
|
||||||
|
let _ = self.ap_repo.accept_note(
|
||||||
|
&ap_id, &author_id, ¬e.content, note.published,
|
||||||
|
note.sensitive, note.content_warning, "public",
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire new deps in `worker/src/factory.rs`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### `lib/api.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Enriched RemoteActorSchema (same endpoint, more fields)
|
||||||
|
export const ProfileFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RemoteActorSchema = z.object({
|
||||||
|
handle: z.string(),
|
||||||
|
displayName: z.string().nullable(),
|
||||||
|
avatarUrl: z.string().nullable(),
|
||||||
|
url: z.string(),
|
||||||
|
bio: z.string().nullable(),
|
||||||
|
bannerUrl: z.string().nullable(),
|
||||||
|
alsoKnownAs: z.string().nullable(),
|
||||||
|
outboxUrl: z.string().nullable(),
|
||||||
|
attachment: z.array(ProfileFieldSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getRemoteActorPosts = (handle: string, page: number, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||||
|
{},
|
||||||
|
z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/users/[username]/page.tsx`
|
||||||
|
|
||||||
|
Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render `<RemoteUserProfile>`. Otherwise: existing local profile.
|
||||||
|
|
||||||
|
### New `components/remote-user-profile.tsx`
|
||||||
|
|
||||||
|
Client component showing:
|
||||||
|
- Banner (`bannerUrl`) — full-width image or placeholder
|
||||||
|
- Avatar + display name + handle (`@user@instance`)
|
||||||
|
- Bio (rendered as text)
|
||||||
|
- Profile fields (`attachment`) — key-value table
|
||||||
|
- "Also known as" link (if present)
|
||||||
|
- External profile link button → `url` in new tab
|
||||||
|
- Follow button (reuse `followUser(handle, token)`)
|
||||||
|
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
|
||||||
|
- Pagination controls
|
||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
getTopFriends,
|
getTopFriends,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
getUserThoughts,
|
getUserThoughts,
|
||||||
|
lookupRemoteActor,
|
||||||
|
getRemoteActorPosts,
|
||||||
Me,
|
Me,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { RemoteUserProfile } from "@/components/remote-user-profile";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
import { Calendar, Settings } from "lucide-react";
|
import { Calendar, Settings } from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -27,6 +30,28 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||||
|
|
||||||
|
if (HANDLE_RE.test(username)) {
|
||||||
|
const [actorResult, postsResult, meResult] = await Promise.allSettled([
|
||||||
|
lookupRemoteActor(username, token),
|
||||||
|
getRemoteActorPosts(username, 1, token),
|
||||||
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (actorResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = actorResult.value as Awaited<ReturnType<typeof lookupRemoteActor>>;
|
||||||
|
const posts =
|
||||||
|
postsResult.status === "fulfilled" ? postsResult.value.items : [];
|
||||||
|
const me =
|
||||||
|
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
|
||||||
|
|
||||||
|
return <RemoteUserProfile actor={actor} initialPosts={posts} me={me} />;
|
||||||
|
}
|
||||||
|
|
||||||
const userProfilePromise = getUserProfile(username, token);
|
const userProfilePromise = getUserProfile(username, token);
|
||||||
const thoughtsPromise = getUserThoughts(username, token);
|
const thoughtsPromise = getUserThoughts(username, token);
|
||||||
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
||||||
|
|||||||
179
thoughts-frontend/components/remote-user-profile.tsx
Normal file
179
thoughts-frontend/components/remote-user-profile.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { ThoughtList } from "@/components/thought-list";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ExternalLink, UserPlus, UserMinus } from "lucide-react";
|
||||||
|
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
interface RemoteUserProfileProps {
|
||||||
|
actor: RemoteActor;
|
||||||
|
initialPosts: Thought[];
|
||||||
|
me: Me | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteUserProfile({
|
||||||
|
actor,
|
||||||
|
initialPosts,
|
||||||
|
me,
|
||||||
|
}: RemoteUserProfileProps) {
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const handleFollow = async () => {
|
||||||
|
if (!token) {
|
||||||
|
toast.error("You must be logged in to follow users.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (followed) {
|
||||||
|
await unfollowUser(actor.handle, token);
|
||||||
|
setFollowed(false);
|
||||||
|
} else {
|
||||||
|
await followUser(actor.handle, token);
|
||||||
|
setFollowed(true);
|
||||||
|
toast.success(`Follow request sent to ${actor.handle}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
followed ? "Failed to unfollow." : "Failed to send follow request."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOwnProfile = me?.username === actor.handle;
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
initialPosts.forEach((t) => {
|
||||||
|
authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="h-48 bg-muted bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: actor.bannerUrl
|
||||||
|
? `url(${actor.bannerUrl})`
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
<aside className="col-span-1 space-y-6">
|
||||||
|
<div className="sticky top-20 space-y-6">
|
||||||
|
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||||
|
<UserAvatar
|
||||||
|
src={actor.avatarUrl}
|
||||||
|
alt={actor.displayName}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isOwnProfile && token && (
|
||||||
|
<Button
|
||||||
|
onClick={handleFollow}
|
||||||
|
disabled={loading}
|
||||||
|
variant={followed ? "secondary" : "default"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{followed ? (
|
||||||
|
<>
|
||||||
|
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{actor.displayName ?? actor.handle}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{actor.handle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actor.bio && (
|
||||||
|
<p className="mt-4 text-sm whitespace-pre-wrap">{actor.bio}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4 w-full"
|
||||||
|
>
|
||||||
|
<Link href={actor.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
View on {new URL(actor.url).hostname}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{actor.alsoKnownAs && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Also known as:{" "}
|
||||||
|
<Link
|
||||||
|
href={actor.alsoKnownAs}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{actor.alsoKnownAs}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actor.attachment.length > 0 && (
|
||||||
|
<table className="mt-4 w-full text-sm border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{actor.attachment.map((field) => (
|
||||||
|
<tr key={field.name} className="border-t">
|
||||||
|
<td className="py-1 pr-2 font-medium text-muted-foreground">
|
||||||
|
{field.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: field.value }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="col-span-1 lg:col-span-3 space-y-4">
|
||||||
|
{initialPosts.length > 0 ? (
|
||||||
|
<ThoughtList
|
||||||
|
thoughts={initialPosts}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card className="flex items-center justify-center h-48">
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
Posts are being fetched — check back soon.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,11 +15,22 @@ export const UserSchema = z.object({
|
|||||||
|
|
||||||
export const MeSchema = UserSchema;
|
export const MeSchema = UserSchema;
|
||||||
|
|
||||||
|
export const ProfileFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
|
||||||
|
|
||||||
export const RemoteActorSchema = z.object({
|
export const RemoteActorSchema = z.object({
|
||||||
handle: z.string(),
|
handle: z.string(),
|
||||||
displayName: z.string().nullable(),
|
displayName: z.string().nullable(),
|
||||||
avatarUrl: z.string().nullable(),
|
avatarUrl: z.string().nullable(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
|
bio: z.string().nullable(),
|
||||||
|
bannerUrl: z.string().nullable(),
|
||||||
|
alsoKnownAs: z.string().nullable(),
|
||||||
|
outboxUrl: z.string().nullable(),
|
||||||
|
attachment: z.array(ProfileFieldSchema),
|
||||||
});
|
});
|
||||||
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
||||||
|
|
||||||
@@ -240,6 +251,23 @@ export const lookupRemoteActor = (handle: string, token: string | null) =>
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getRemoteActorPosts = (
|
||||||
|
handle: string,
|
||||||
|
page: number,
|
||||||
|
token: string | null
|
||||||
|
) =>
|
||||||
|
apiFetch(
|
||||||
|
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||||
|
{},
|
||||||
|
z.object({
|
||||||
|
total: z.number(),
|
||||||
|
page: z.number(),
|
||||||
|
per_page: z.number(),
|
||||||
|
items: z.array(ThoughtSchema),
|
||||||
|
}),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
`/users?page=${page}&per_page=${pageSize}`,
|
`/users?page=${page}&per_page=${pageSize}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user