Compare commits

..

9 Commits

25 changed files with 2203 additions and 7 deletions

View File

@@ -1394,6 +1394,15 @@ impl domain::ports::FederationActionPort for ActivityPubService {
public_key: actor.public_key_pem.clone(),
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
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)
.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)]

View File

@@ -68,6 +68,10 @@ pub enum EventPayload {
UserRegistered {
user_id: String,
},
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
},
}
impl EventPayload {
@@ -88,6 +92,7 @@ impl EventPayload {
Self::UserBlocked { .. } => "users.blocked",
Self::UserUnblocked { .. } => "users.unblocked",
Self::UserRegistered { .. } => "users.registered",
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
}
}
}
@@ -197,6 +202,13 @@ impl From<&DomainEvent> for EventPayload {
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
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 {
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(),
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();
subjects.sort();

View File

@@ -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"
).bind(url).fetch_optional(&self.pool).await
.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![] }))
}
}

View File

@@ -88,6 +88,13 @@ pub struct CreatedApiKeyResponse {
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)]
#[serde(rename_all = "camelCase")]
pub struct RemoteActorResponse {
@@ -95,4 +102,9 @@ pub struct RemoteActorResponse {
pub display_name: Option<String>,
pub avatar_url: Option<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>,
}

View File

@@ -11,6 +11,8 @@ uuid = { workspace = true }
chrono = { workspace = true }
sha2 = "0.10"
hex = "0.4"
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::thought::{Thought, Visibility},
ports::{OutboundFederationPort, ThoughtRepository, UserRepository},
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository},
value_objects::ThoughtId,
};
use std::sync::Arc;
@@ -12,6 +12,8 @@ pub struct FederationEventService {
pub users: Arc<dyn UserRepository>,
pub ap: Arc<dyn OutboundFederationPort>,
pub base_url: String,
pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>,
}
impl FederationEventService {
@@ -112,6 +114,49 @@ impl FederationEventService {
.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(&note.ap_id) {
Ok(u) => u,
Err(_) => continue,
};
let _ = self
.ap_repo
.accept_note(
&ap_id,
&author_id,
&note.content,
note.published,
note.sensitive,
note.content_warning,
"public",
)
.await;
}
Ok(())
}
_ => Ok(()),
}
}
@@ -126,7 +171,7 @@ mod tests {
events::DomainEvent,
models::thought::{Thought, Visibility},
models::user::User,
ports::OutboundFederationPort,
ports::{ActivityPubRepository, OutboundFederationPort},
testing::TestStore,
value_objects::*,
};
@@ -208,6 +253,8 @@ mod tests {
users: Arc::new(store.clone()),
ap: spy,
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());
}
#[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
}
}

View File

@@ -110,6 +110,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
hasher: Arc::new(auth::Argon2PasswordHasher),
events: event_publisher,
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
};
Infrastructure { state, ap_service }

View File

@@ -60,6 +60,10 @@ pub enum DomainEvent {
UserRegistered {
user_id: UserId,
},
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
},
}
pub struct EventEnvelope {

View File

@@ -2,6 +2,7 @@ pub mod api_key;
pub mod feed;
pub mod notification;
pub mod remote_actor;
pub mod remote_note;
pub mod social;
pub mod tag;
pub mod thought;

View File

@@ -10,4 +10,9 @@ pub struct RemoteActor {
pub public_key: String,
pub avatar_url: Option<String>,
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)>,
}

View 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>,
}

View File

@@ -209,6 +209,11 @@ pub trait FederationActionPort: Send + Sync {
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError>;
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
}
#[async_trait]

View File

@@ -567,6 +567,14 @@ impl FederationActionPort for TestStore {
) -> Result<String, DomainError> {
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]
@@ -833,6 +841,16 @@ mod federation_port_tests {
let err = store.actor_json(&UserId::new()).await.unwrap_err();
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)]

View 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);
}
}

View File

@@ -21,7 +21,7 @@ use axum::{
use domain::models::feed::PageParams;
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 {
id: e.thought.id.as_uuid(),
content: e.thought.content.as_str().to_string(),

View File

@@ -1,5 +1,6 @@
pub mod api_keys;
pub mod auth;
pub mod federation_actors;
pub mod feed;
pub mod health;
pub mod notifications;

View File

@@ -6,7 +6,7 @@ use crate::{
};
use api_types::{
requests::{PaginationQuery, UpdateProfileRequest},
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
};
use application::use_cases::feed::list_users;
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,
avatar_url: actor.avatar_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),
events: store.clone(),
federation: store.clone(),
ap_repo: store.clone(),
}
}

View File

@@ -65,6 +65,10 @@ pub fn router() -> Router<AppState> {
.route("/feed", get(feed::home_feed))
.route("/feed/public", get(feed::public_feed))
.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/{name}", get(feed::tag_thoughts_handler))
// notifications

View File

@@ -20,4 +20,5 @@ pub struct AppState {
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
pub federation: Arc<dyn FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>,
}

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::ActivityPubService;
use application::services::{FederationEventService, NotificationEventService};
use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};
use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
@@ -34,7 +35,7 @@ pub async fn build(
));
// ActivityPub service (for federation fan-out)
let ap_service: Arc<dyn domain::ports::OutboundFederationPort> = Arc::new(
let ap_service = Arc::new(
ActivityPubService::new(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(
@@ -54,6 +55,10 @@ pub async fn build(
.await
.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
let notification_svc = Arc::new(NotificationEventService {
@@ -63,8 +68,10 @@ pub async fn build(
let federation_svc = Arc::new(FederationEventService {
thoughts,
users,
ap: ap_service,
ap: ap_outbound,
base_url: base_url.to_string(),
federation_action: ap_federation,
ap_repo: ap_repo_worker,
});
// Thin handlers

File diff suppressed because it is too large Load Diff

View 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(&note.ap_id) {
Ok(u) => u,
Err(_) => continue,
};
// accept_note is idempotent — ignore duplicate errors
let _ = self.ap_repo.accept_note(
&ap_id, &author_id, &note.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

View File

@@ -5,8 +5,11 @@ import {
getTopFriends,
getUserProfile,
getUserThoughts,
lookupRemoteActor,
getRemoteActorPosts,
Me,
} from "@/lib/api";
import { RemoteUserProfile } from "@/components/remote-user-profile";
import { UserAvatar } from "@/components/user-avatar";
import { Calendar, Settings } from "lucide-react";
import { Card } from "@/components/ui/card";
@@ -27,6 +30,28 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const { username } = await params;
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 thoughtsPromise = getUserThoughts(username, token);
const mePromise = token ? getMe(token) : Promise.resolve(null);

View 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>
);
}

View File

@@ -15,11 +15,22 @@ export const UserSchema = z.object({
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({
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 type RemoteActor = z.infer<typeof RemoteActorSchema>;
@@ -240,6 +251,23 @@ export const lookupRemoteActor = (handle: string, token: string | null) =>
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) =>
apiFetch(
`/users?page=${page}&per_page=${pageSize}`,