federation improvements

This commit is contained in:
2026-05-09 15:45:08 +02:00
parent fa6eacb39f
commit 69f6587623
15 changed files with 241 additions and 24 deletions

View File

@@ -61,6 +61,7 @@ impl Activity for FollowActivity {
local_actor.user_id.clone(),
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
@@ -292,7 +293,7 @@ pub struct UpdateActivity {
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ReviewObject,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
@@ -309,19 +310,26 @@ impl Activity for UpdateActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.object.attributed_to.inner() != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"update actor does not match object attributed_to"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let ap_id = self.object.id.inner().as_str();
let rating = self.object.rating.min(5);
let comment = self.object.comment.as_deref();
let watched_at = self.object.watched_at.naive_utc();
let object: ReviewObject = match serde_json::from_value(self.object) {
Ok(o) => o,
Err(_) => {
tracing::debug!(actor = %self.actor.inner(), "ignoring non-review Update activity");
return Ok(());
}
};
if object.attributed_to.inner() != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"update actor does not match object attributed_to"
)));
}
let ap_id = object.id.inner().as_str();
let rating = object.rating.min(5);
let comment = object.comment.as_deref();
let watched_at = object.watched_at.naive_utc();
data.federation_repo
.update_remote_review(ap_id, self.actor.inner().as_str(), rating, comment, watched_at)
.await?;

View File

@@ -26,6 +26,7 @@ impl ApFederationConfig {
.domain(&data.domain)
.app_data(data)
.debug(true)
.http_signature_compat(true)
.url_verifier(Box::new(PermissiveVerifier))
.build()
.await?
@@ -34,6 +35,7 @@ impl ApFederationConfig {
.domain(&data.domain)
.app_data(data)
.debug(false)
.http_signature_compat(true)
.build()
.await?
};

View File

@@ -34,7 +34,8 @@ pub struct Follower {
#[async_trait]
pub trait FederationRepository: Send + Sync {
async fn add_follower(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus) -> Result<()>;
async fn add_follower(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>;
async fn get_follower_follow_activity_id(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<Option<String>>;
async fn remove_follower(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<()>;
async fn get_followers(&self, local_user_id: UserId) -> Result<Vec<Follower>>;
async fn update_follower_status(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus) -> Result<()>;

View File

@@ -244,7 +244,12 @@ impl ActivityPubService {
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id_str = data
.federation_repo
.get_follower_follow_activity_id(local_user_id.clone(), remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("follow activity id not found for {}", remote_actor_url))?;
let follow_id = Url::parse(&follow_id_str)?;
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
@@ -341,6 +346,37 @@ impl ActivityPubService {
.await
}
pub async fn get_accepted_followers(
&self,
local_user_id: UserId,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.map(|f| f.actor)
.collect())
}
pub async fn count_accepted_followers(&self, local_user_id: UserId) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.count())
}
pub async fn remove_follower(
&self,
local_user_id: UserId,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.remove_follower(local_user_id, actor_url).await
}
fn spawn_backfill(&self, owner_user_id: UserId, follower_inbox_url: String) {
let config = self.federation_config.clone();
let base_url = self.base_url.clone();
@@ -369,10 +405,11 @@ impl ActivityPubService {
let inbox = Url::parse(&follower_inbox_url)?;
let history = data.movie_repo.get_user_history(&owner_user_id).await?;
let local_reviews: Vec<_> = history
let mut local_reviews: Vec<_> = history
.into_iter()
.filter(|e| matches!(e.review().source(), domain::models::ReviewSource::Local))
.collect();
local_reviews.reverse(); // oldest first so Mastodon feed is chronological
let total = local_reviews.len();
@@ -418,12 +455,14 @@ impl ActivityPubService {
use activitypub_federation::traits::Object;
use crate::objects::DbReview;
let ap_id = crate::urls::review_url(base_url, review.id());
let review_id = review.id().clone();
let ap_id = crate::urls::review_url(base_url, &review_id);
let db_review = DbReview { review, ap_id };
let object = db_review.into_json(data).await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let activity_id = crate::urls::activity_url(base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let activity_id = crate::urls::create_activity_url(base_url, &review_id)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let create = CreateActivity {
id: activity_id,
kind: Default::default(),

View File

@@ -29,3 +29,10 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.expect("base_url is always a valid URL prefix")
}
/// Stable Create-activity URL derived from review ID.
/// Deterministic so repeated backfills to different followers don't create duplicate posts.
pub fn create_activity_url(base_url: &str, review_id: &ReviewId) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/create/{}", base_url, review_id.value()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}

View File

@@ -0,0 +1 @@
ALTER TABLE ap_followers ADD COLUMN follow_activity_id TEXT;

View File

@@ -42,6 +42,7 @@ impl FederationRepository for SqliteFederationRepository {
local_user_id: UserId,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.value().to_string();
let status_str = status_to_str(&status);
@@ -49,20 +50,39 @@ impl FederationRepository for SqliteFederationRepository {
let created_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
status = excluded.status,
follow_activity_id = excluded.follow_activity_id",
)
.bind(&uid)
.bind(remote_actor_url)
.bind(status_str)
.bind(&created_at)
.bind(follow_activity_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_follower_follow_activity_id(
&self,
local_user_id: UserId,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.value().to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
}
async fn remove_follower(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<()> {
let uid = local_user_id.value().to_string();

View File

@@ -1,8 +1,8 @@
use askama::Template;
use chrono::Datelike;
use application::ports::{
ActivityFeedPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, LoginPageData,
NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
};
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats,
@@ -124,6 +124,7 @@ struct ProfileTemplate<'a> {
is_own_profile: bool,
error: Option<String>,
following_count: usize,
followers_count: usize,
pending_followers: Vec<RemoteActorData>,
}
@@ -142,6 +143,15 @@ struct FollowingTemplate {
error: Option<String>,
}
#[derive(Template)]
#[template(path = "followers.html")]
struct FollowersTemplate {
ctx: HtmlPageContext,
user_id: uuid::Uuid,
actors: Vec<RemoteActorData>,
error: Option<String>,
}
struct HeatmapCell {
month_label: String,
count: i64,
@@ -338,6 +348,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
is_own_profile: data.is_own_profile,
error: data.error,
following_count: data.following_count,
followers_count: data.followers_count,
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData {
handle: a.handle,
url: a.url,
@@ -362,4 +373,19 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.render()
.map_err(|e| e.to_string())
}
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String> {
FollowersTemplate {
ctx: data.ctx,
user_id: data.user_id,
actors: data.actors.into_iter().map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
}).collect(),
error: data.error,
}
.render()
.map_err(|e| e.to_string())
}
}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<h2>Followers</h2>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
{% if actors.is_empty() %}
<p>No followers yet.</p>
{% else %}
<ul class="following-list">
{% for actor in actors %}
<li class="following-item">
<strong>{{ actor.handle }}</strong>
{% if let Some(name) = actor.display_name %}
({{ name }})
{% endif %}
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer">{{ actor.url }}</a>
<form method="POST" action="/users/{{ user_id }}/followers/remove" style="display:inline">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<button type="submit">Remove</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -36,6 +36,7 @@
{% endif %}
</section>
<a href="/users/{{ profile_user_id }}/following-list">View following ({{ following_count }})</a>
<a href="/users/{{ profile_user_id }}/followers-list">View followers ({{ followers_count }})</a>
{% if !pending_followers.is_empty() %}
<section class="pending-followers">
<h3>Pending follow requests ({{ pending_followers.len() }})</h3>