federation improvements
This commit is contained in:
@@ -40,7 +40,9 @@ RUN sqlite3 /build/dev.db \
|
|||||||
sqlite3 /build/dev.db \
|
sqlite3 /build/dev.db \
|
||||||
< crates/adapters/sqlite/migrations/0004_username.sql && \
|
< crates/adapters/sqlite/migrations/0004_username.sql && \
|
||||||
sqlite3 /build/dev.db \
|
sqlite3 /build/dev.db \
|
||||||
< crates/adapters/sqlite/migrations/0005_activitypub_v2.sql
|
< crates/adapters/sqlite/migrations/0005_activitypub_v2.sql && \
|
||||||
|
sqlite3 /build/dev.db \
|
||||||
|
< crates/adapters/sqlite/migrations/0006_follower_activity_id.sql
|
||||||
|
|
||||||
ENV DATABASE_URL=sqlite:///build/dev.db
|
ENV DATABASE_URL=sqlite:///build/dev.db
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ impl Activity for FollowActivity {
|
|||||||
local_actor.user_id.clone(),
|
local_actor.user_id.clone(),
|
||||||
self.actor.inner().as_str(),
|
self.actor.inner().as_str(),
|
||||||
FollowerStatus::Pending,
|
FollowerStatus::Pending,
|
||||||
|
self.id.as_str(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ pub struct UpdateActivity {
|
|||||||
#[serde(rename = "type", default)]
|
#[serde(rename = "type", default)]
|
||||||
pub(crate) kind: UpdateType,
|
pub(crate) kind: UpdateType,
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
pub(crate) object: ReviewObject,
|
pub(crate) object: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -309,19 +310,26 @@ impl Activity for UpdateActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
let ap_id = self.object.id.inner().as_str();
|
let object: ReviewObject = match serde_json::from_value(self.object) {
|
||||||
let rating = self.object.rating.min(5);
|
Ok(o) => o,
|
||||||
let comment = self.object.comment.as_deref();
|
Err(_) => {
|
||||||
let watched_at = self.object.watched_at.naive_utc();
|
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
|
data.federation_repo
|
||||||
.update_remote_review(ap_id, self.actor.inner().as_str(), rating, comment, watched_at)
|
.update_remote_review(ap_id, self.actor.inner().as_str(), rating, comment, watched_at)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ impl ApFederationConfig {
|
|||||||
.domain(&data.domain)
|
.domain(&data.domain)
|
||||||
.app_data(data)
|
.app_data(data)
|
||||||
.debug(true)
|
.debug(true)
|
||||||
|
.http_signature_compat(true)
|
||||||
.url_verifier(Box::new(PermissiveVerifier))
|
.url_verifier(Box::new(PermissiveVerifier))
|
||||||
.build()
|
.build()
|
||||||
.await?
|
.await?
|
||||||
@@ -34,6 +35,7 @@ impl ApFederationConfig {
|
|||||||
.domain(&data.domain)
|
.domain(&data.domain)
|
||||||
.app_data(data)
|
.app_data(data)
|
||||||
.debug(false)
|
.debug(false)
|
||||||
|
.http_signature_compat(true)
|
||||||
.build()
|
.build()
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ pub struct Follower {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FederationRepository: Send + Sync {
|
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 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 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<()>;
|
async fn update_follower_status(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus) -> Result<()>;
|
||||||
|
|||||||
@@ -244,7 +244,12 @@ impl ActivityPubService {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
|
.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 {
|
let follow = FollowActivity {
|
||||||
id: follow_id,
|
id: follow_id,
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
@@ -341,6 +346,37 @@ impl ActivityPubService {
|
|||||||
.await
|
.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) {
|
fn spawn_backfill(&self, owner_user_id: UserId, follower_inbox_url: String) {
|
||||||
let config = self.federation_config.clone();
|
let config = self.federation_config.clone();
|
||||||
let base_url = self.base_url.clone();
|
let base_url = self.base_url.clone();
|
||||||
@@ -369,10 +405,11 @@ impl ActivityPubService {
|
|||||||
let inbox = Url::parse(&follower_inbox_url)?;
|
let inbox = Url::parse(&follower_inbox_url)?;
|
||||||
|
|
||||||
let history = data.movie_repo.get_user_history(&owner_user_id).await?;
|
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()
|
.into_iter()
|
||||||
.filter(|e| matches!(e.review().source(), domain::models::ReviewSource::Local))
|
.filter(|e| matches!(e.review().source(), domain::models::ReviewSource::Local))
|
||||||
.collect();
|
.collect();
|
||||||
|
local_reviews.reverse(); // oldest first so Mastodon feed is chronological
|
||||||
|
|
||||||
let total = local_reviews.len();
|
let total = local_reviews.len();
|
||||||
|
|
||||||
@@ -418,12 +455,14 @@ impl ActivityPubService {
|
|||||||
use activitypub_federation::traits::Object;
|
use activitypub_federation::traits::Object;
|
||||||
use crate::objects::DbReview;
|
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 db_review = DbReview { review, ap_id };
|
||||||
let object = db_review.into_json(data).await
|
let object = db_review.into_json(data).await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.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 {
|
let create = CreateActivity {
|
||||||
id: activity_id,
|
id: activity_id,
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
|
|||||||
@@ -29,3 +29,10 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
|||||||
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
||||||
.expect("base_url is always a valid URL prefix")
|
.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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE ap_followers ADD COLUMN follow_activity_id TEXT;
|
||||||
@@ -42,6 +42,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
local_user_id: UserId,
|
local_user_id: UserId,
|
||||||
remote_actor_url: &str,
|
remote_actor_url: &str,
|
||||||
status: FollowerStatus,
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let uid = local_user_id.value().to_string();
|
let uid = local_user_id.value().to_string();
|
||||||
let status_str = status_to_str(&status);
|
let status_str = status_to_str(&status);
|
||||||
@@ -49,20 +50,39 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let created_at = datetime_to_str(&now);
|
let created_at = datetime_to_str(&now);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
follow_activity_id = excluded.follow_activity_id",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.bind(remote_actor_url)
|
.bind(remote_actor_url)
|
||||||
.bind(status_str)
|
.bind(status_str)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
|
.bind(follow_activity_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
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<()> {
|
async fn remove_follower(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<()> {
|
||||||
let uid = local_user_id.value().to_string();
|
let uid = local_user_id.value().to_string();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use application::ports::{
|
use application::ports::{
|
||||||
ActivityFeedPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, LoginPageData,
|
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||||
NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
|
LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
|
||||||
};
|
};
|
||||||
use domain::models::{
|
use domain::models::{
|
||||||
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats,
|
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats,
|
||||||
@@ -124,6 +124,7 @@ struct ProfileTemplate<'a> {
|
|||||||
is_own_profile: bool,
|
is_own_profile: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
following_count: usize,
|
following_count: usize,
|
||||||
|
followers_count: usize,
|
||||||
pending_followers: Vec<RemoteActorData>,
|
pending_followers: Vec<RemoteActorData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,15 @@ struct FollowingTemplate {
|
|||||||
error: Option<String>,
|
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 {
|
struct HeatmapCell {
|
||||||
month_label: String,
|
month_label: String,
|
||||||
count: i64,
|
count: i64,
|
||||||
@@ -338,6 +348,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
|||||||
is_own_profile: data.is_own_profile,
|
is_own_profile: data.is_own_profile,
|
||||||
error: data.error,
|
error: data.error,
|
||||||
following_count: data.following_count,
|
following_count: data.following_count,
|
||||||
|
followers_count: data.followers_count,
|
||||||
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData {
|
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData {
|
||||||
handle: a.handle,
|
handle: a.handle,
|
||||||
url: a.url,
|
url: a.url,
|
||||||
@@ -362,4 +373,19 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
|||||||
.render()
|
.render()
|
||||||
.map_err(|e| e.to_string())
|
.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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
crates/adapters/template-askama/templates/followers.html
Normal file
26
crates/adapters/template-askama/templates/followers.html
Normal 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 %}
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
<a href="/users/{{ profile_user_id }}/following-list">View following ({{ following_count }})</a>
|
<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() %}
|
{% if !pending_followers.is_empty() %}
|
||||||
<section class="pending-followers">
|
<section class="pending-followers">
|
||||||
<h3>Pending follow requests ({{ pending_followers.len() }})</h3>
|
<h3>Pending follow requests ({{ pending_followers.len() }})</h3>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub struct ProfilePageData {
|
|||||||
pub is_own_profile: bool,
|
pub is_own_profile: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub following_count: usize,
|
pub following_count: usize,
|
||||||
|
pub followers_count: usize,
|
||||||
pub pending_followers: Vec<RemoteActorView>,
|
pub pending_followers: Vec<RemoteActorView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +77,13 @@ pub struct FollowingPageData {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FollowersPageData {
|
||||||
|
pub ctx: HtmlPageContext,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub actors: Vec<RemoteActorView>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait HtmlRenderer: Send + Sync {
|
pub trait HtmlRenderer: Send + Sync {
|
||||||
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
|
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
|
||||||
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
|
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
|
||||||
@@ -85,6 +93,7 @@ pub trait HtmlRenderer: Send + Sync {
|
|||||||
fn render_users_page(&self, data: UsersPageData) -> Result<String, String>;
|
fn render_users_page(&self, data: UsersPageData) -> Result<String, String>;
|
||||||
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
|
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
|
||||||
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
|
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
|
||||||
|
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RssFeedRenderer: Send + Sync {
|
pub trait RssFeedRenderer: Send + Sync {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ mod tests {
|
|||||||
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicRssRenderer;
|
struct PanicRssRenderer;
|
||||||
@@ -313,6 +314,7 @@ mod tests {
|
|||||||
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicRssRenderer2;
|
struct PanicRssRenderer2;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
||||||
@@ -375,6 +377,7 @@ mod tests {
|
|||||||
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicRssRenderer3;
|
struct PanicRssRenderer3;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub mod html {
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
||||||
ports::{FollowingPageData, HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView},
|
ports::{FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView},
|
||||||
use_cases::{delete_review, log_review, login as login_uc, register as register_uc},
|
use_cases::{delete_review, log_review, login as login_uc, register as register_uc},
|
||||||
};
|
};
|
||||||
use domain::{errors::DomainError, value_objects::UserId};
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
@@ -340,8 +340,7 @@ pub mod html {
|
|||||||
|
|
||||||
let following_count = if is_own_profile {
|
let following_count = if is_own_profile {
|
||||||
if let Some(ref uid) = user_id {
|
if let Some(ref uid) = user_id {
|
||||||
state.ap_service.count_following(uid.clone()).await
|
state.ap_service.count_following(uid.clone()).await.unwrap_or(0)
|
||||||
.unwrap_or(0)
|
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
@@ -349,6 +348,15 @@ pub mod html {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let followers_count = if is_own_profile {
|
||||||
|
state.ap_service
|
||||||
|
.count_accepted_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||||
|
.await
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
let pending_followers = if is_own_profile {
|
let pending_followers = if is_own_profile {
|
||||||
state.ap_service
|
state.ap_service
|
||||||
.get_pending_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
.get_pending_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||||
@@ -396,6 +404,7 @@ pub mod html {
|
|||||||
is_own_profile,
|
is_own_profile,
|
||||||
error: params.error,
|
error: params.error,
|
||||||
following_count,
|
following_count,
|
||||||
|
followers_count,
|
||||||
pending_followers,
|
pending_followers,
|
||||||
};
|
};
|
||||||
match state.html_renderer.render_profile_page(data) {
|
match state.html_renderer.render_profile_page(data) {
|
||||||
@@ -516,6 +525,61 @@ pub mod html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers_page(
|
||||||
|
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(profile_user_uuid): Path<Uuid>,
|
||||||
|
Query(params): Query<crate::dtos::ErrorQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if user_id.value() != profile_user_uuid {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
let mut ctx = build_page_context(&state, Some(user_id.clone())).await;
|
||||||
|
ctx.page_title = "Followers — Movies Diary".to_string();
|
||||||
|
ctx.canonical_url = format!("{}/users/{}/followers-list", state.app_ctx.config.base_url, profile_user_uuid);
|
||||||
|
match state.ap_service.get_accepted_followers(user_id).await {
|
||||||
|
Ok(followers) => {
|
||||||
|
let actors = followers.into_iter().map(|a| RemoteActorView {
|
||||||
|
handle: a.handle,
|
||||||
|
display_name: a.display_name,
|
||||||
|
url: a.url,
|
||||||
|
}).collect();
|
||||||
|
let data = FollowersPageData {
|
||||||
|
ctx,
|
||||||
|
user_id: profile_user_uuid,
|
||||||
|
actors,
|
||||||
|
error: params.error,
|
||||||
|
};
|
||||||
|
match state.html_renderer.render_followers_page(data) {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_followers error: {:?}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load followers list").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_follower(
|
||||||
|
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(profile_user_uuid): Path<Uuid>,
|
||||||
|
Form(form): Form<FollowerActionForm>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if user_id.value() != profile_user_uuid {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
match state.ap_service.remove_follower(user_id, &form.actor_url).await {
|
||||||
|
Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let msg = encode_error(&e.to_string());
|
||||||
|
Redirect::to(&format!("/users/{}/followers-list?error={}", profile_user_uuid, msg)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod posters {
|
pub mod posters {
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
"/users/{id}/following-list",
|
"/users/{id}/following-list",
|
||||||
routing::get(handlers::html::get_following_page),
|
routing::get(handlers::html::get_following_page),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/followers-list",
|
||||||
|
routing::get(handlers::html::get_followers_page),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/followers/remove",
|
||||||
|
routing::post(handlers::html::remove_follower),
|
||||||
|
)
|
||||||
.merge(auth)
|
.merge(auth)
|
||||||
.route(
|
.route(
|
||||||
"/reviews/new",
|
"/reviews/new",
|
||||||
|
|||||||
Reference in New Issue
Block a user