feat: discoverability (NodeInfo, hashtags) and moderation (domain/actor blocking)
- NodeInfo at /.well-known/nodeinfo + /nodeinfo/2.0
- Hashtags #MoviesDiary + #MovieTitle on review posts; /tags/{tag} redirect
- Domain blocking: blocked_domains table, admin API + HTML, inbox enforcement
- Per-actor blocking: blocked_actors table, user API + HTML, BlockActivity send/receive
- Delivery filter excludes blocked actors and blocked-domain inboxes
This commit is contained in:
@@ -63,9 +63,22 @@ impl Activity for FollowActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let _follower = self.actor.dereference(data).await?;
|
||||
let local_actor = self.object.dereference(data).await?;
|
||||
|
||||
if data.federation_repo
|
||||
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
|
||||
.await?
|
||||
{
|
||||
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
data.federation_repo
|
||||
.add_follower(
|
||||
local_actor.user_id,
|
||||
@@ -114,6 +127,11 @@ impl Activity for AcceptActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
|
||||
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
|
||||
data.federation_repo
|
||||
@@ -158,6 +176,11 @@ impl Activity for RejectActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
|
||||
data.federation_repo
|
||||
.remove_following(user_id, self.actor.inner().as_str())
|
||||
@@ -198,6 +221,11 @@ impl Activity for UndoActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
|
||||
data.federation_repo
|
||||
.remove_follower(user_id, self.actor.inner().as_str())
|
||||
@@ -242,6 +270,11 @@ impl Activity for CreateActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
@@ -283,6 +316,11 @@ impl Activity for DeleteActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_delete(&self.object, &actor_url)
|
||||
@@ -323,6 +361,11 @@ impl Activity for UpdateActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
@@ -365,6 +408,11 @@ impl Activity for AnnounceActivity {
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let object_domain = self.object.host_str().unwrap_or("");
|
||||
if object_domain != data.domain {
|
||||
return Ok(());
|
||||
@@ -382,6 +430,57 @@ impl Activity for AnnounceActivity {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Block")]
|
||||
pub struct BlockType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: BlockType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for BlockActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
// They blocked us — remove them from our following list
|
||||
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
|
||||
let _ = data
|
||||
.federation_repo
|
||||
.remove_following(local_user_id, self.actor.inner().as_str())
|
||||
.await;
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "received block");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox dispatch enum ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -404,4 +503,6 @@ pub enum InboxActivities {
|
||||
Update(UpdateActivity),
|
||||
#[serde(rename = "Announce")]
|
||||
Announce(AnnounceActivity),
|
||||
#[serde(rename = "Block")]
|
||||
Block(BlockActivity),
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ pub struct DbActor {
|
||||
pub ap_id: Url,
|
||||
pub last_refreshed_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_path: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub profile_url: Option<Url>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -105,7 +106,8 @@ pub async fn get_local_actor(
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: user.bio,
|
||||
avatar_path: user.avatar_path,
|
||||
avatar_url: user.avatar_url,
|
||||
profile_url: user.profile_url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,7 +166,8 @@ impl Object for DbActor {
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_path: None,
|
||||
avatar_url: None,
|
||||
profile_url: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -175,14 +178,11 @@ impl Object for DbActor {
|
||||
public_key_pem: self.public_key_pem.clone(),
|
||||
};
|
||||
|
||||
let icon = self.avatar_path.as_ref().map(|p| ApImageObject {
|
||||
let icon = self.avatar_url.map(|url| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url: Url::parse(&format!("{}/images/{}", data.base_url, p))
|
||||
.expect("valid avatar url"),
|
||||
url,
|
||||
});
|
||||
let profile_url =
|
||||
Url::parse(&format!("{}/u/{}", data.base_url, self.username))
|
||||
.expect("valid profile url");
|
||||
let profile_url = self.profile_url;
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
@@ -196,7 +196,7 @@ impl Object for DbActor {
|
||||
name: Some(self.username.clone()),
|
||||
summary: self.bio.clone(),
|
||||
icon,
|
||||
url: Some(profile_url),
|
||||
url: profile_url,
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
})
|
||||
@@ -242,7 +242,8 @@ impl Object for DbActor {
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_path: None,
|
||||
avatar_url: None,
|
||||
profile_url: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,7 @@ pub trait ApObjectHandler: Send + Sync {
|
||||
|
||||
/// Actor unfollowed/was removed — clean up all their remote content.
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Total number of locally-authored posts across all users.
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct FederationData {
|
||||
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) domain: String,
|
||||
pub(crate) allow_registration: bool,
|
||||
pub(crate) software_name: String,
|
||||
}
|
||||
|
||||
impl FederationData {
|
||||
@@ -19,6 +21,8 @@ impl FederationData {
|
||||
user_repo: Arc<dyn ApUserRepository>,
|
||||
object_handler: Arc<dyn ApObjectHandler>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
software_name: String,
|
||||
) -> Self {
|
||||
let domain = base_url
|
||||
.trim_start_matches("https://")
|
||||
@@ -33,6 +37,8 @@ impl FederationData {
|
||||
object_handler,
|
||||
base_url,
|
||||
domain,
|
||||
allow_registration,
|
||||
software_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod error;
|
||||
pub mod federation;
|
||||
pub mod followers_handler;
|
||||
pub mod inbox;
|
||||
pub mod nodeinfo;
|
||||
pub mod outbox;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
@@ -19,7 +20,7 @@ pub use data::FederationData;
|
||||
pub use error::Error;
|
||||
pub use federation::ApFederationConfig;
|
||||
pub use repository::{
|
||||
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
pub use service::ActivityPubService;
|
||||
pub use user::{ApUser, ApUserRepository};
|
||||
|
||||
119
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
119
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use activitypub_federation::config::Data;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoWellKnown {
|
||||
pub links: Vec<NodeInfoLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoLink {
|
||||
pub rel: String,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoSoftware {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfoUsage {
|
||||
pub users: NodeInfoUsers,
|
||||
pub local_posts: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoUsers {
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfo {
|
||||
pub version: String,
|
||||
pub software: NodeInfoSoftware,
|
||||
pub protocols: Vec<String>,
|
||||
pub usage: NodeInfoUsage,
|
||||
pub open_registrations: bool,
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_well_known_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfoWellKnown>, Error> {
|
||||
let href = format!("{}/nodeinfo/2.0", data.base_url);
|
||||
Ok(Json(NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href,
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfo>, Error> {
|
||||
let user_count = data.user_repo.count_users().await.unwrap_or(0);
|
||||
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
|
||||
|
||||
Ok(Json(NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: data.software_name.clone(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: user_count },
|
||||
local_posts,
|
||||
},
|
||||
open_registrations: data.allow_registration,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_well_known_serializes_correctly() {
|
||||
let doc = NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href: "https://example.com/nodeinfo/2.0".to_string(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0");
|
||||
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_serializes_camel_case() {
|
||||
let doc = NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: "my-app".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: 3 },
|
||||
local_posts: 42,
|
||||
},
|
||||
open_registrations: false,
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["version"], "2.0");
|
||||
assert_eq!(json["software"]["name"], "my-app");
|
||||
assert_eq!(json["usage"]["users"]["total"], 3);
|
||||
assert_eq!(json["usage"]["localPosts"], 42);
|
||||
assert_eq!(json["openRegistrations"], false);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@ pub struct Follower {
|
||||
pub status: FollowerStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedDomain {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationRepository: Send + Sync {
|
||||
async fn add_follower(
|
||||
@@ -97,4 +104,12 @@ pub trait FederationRepository: Send + Sync {
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()>;
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize>;
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ use crate::{
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
outbox::outbox_handler,
|
||||
repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
|
||||
repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
|
||||
urls::activity_url,
|
||||
user::ApUserRepository,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
webfinger::webfinger_handler,
|
||||
};
|
||||
|
||||
@@ -78,9 +79,11 @@ impl ActivityPubService {
|
||||
user_repo: Arc<dyn ApUserRepository>,
|
||||
object_handler: Arc<dyn ApObjectHandler>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
software_name: String,
|
||||
debug: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone());
|
||||
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone(), allow_registration, software_name);
|
||||
let federation_config = ApFederationConfig::new(data, debug).await?;
|
||||
Ok(Self {
|
||||
federation_config,
|
||||
@@ -112,6 +115,8 @@ impl ActivityPubService {
|
||||
|
||||
pub fn router(&self) -> Router {
|
||||
Router::new()
|
||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||
.route("/users/{id}/inbox", post(inbox_handler))
|
||||
.route("/users/{id}/outbox", get(outbox_handler))
|
||||
@@ -443,9 +448,30 @@ impl ActivityPubService {
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
@@ -526,6 +552,88 @@ impl ActivityPubService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
|
||||
data.federation_repo
|
||||
.add_blocked_actor(local_user_id, actor_url)
|
||||
.await?;
|
||||
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await;
|
||||
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await;
|
||||
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
|
||||
let block_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let block = crate::activities::BlockActivity {
|
||||
id: block_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: Url::parse(actor_url)?,
|
||||
};
|
||||
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(block),
|
||||
&local_actor,
|
||||
vec![inbox],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(actor = %actor_url, "failed to deliver Block activity");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo
|
||||
.remove_blocked_actor(local_user_id, actor_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?;
|
||||
let mut actors = Vec::new();
|
||||
for url in actor_urls {
|
||||
let actor = match data.federation_repo.get_remote_actor(&url).await {
|
||||
Ok(Some(a)) => a,
|
||||
_ => RemoteActor {
|
||||
url: url.clone(),
|
||||
handle: url.clone(),
|
||||
inbox_url: url.clone(),
|
||||
shared_inbox_url: None,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
};
|
||||
actors.push(actor);
|
||||
}
|
||||
Ok(actors)
|
||||
}
|
||||
|
||||
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.add_blocked_domain(domain, reason).await
|
||||
}
|
||||
|
||||
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.remove_blocked_domain(domain).await
|
||||
}
|
||||
|
||||
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.get_blocked_domains().await
|
||||
}
|
||||
|
||||
async fn follow_local(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApUser {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_path: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub profile_url: Option<Url>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApUserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn count_users(&self) -> anyhow::Result<usize>;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ impl ActivityPubEventHandler {
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
|
||||
@@ -25,18 +25,19 @@ pub struct ActivityPubWire {
|
||||
}
|
||||
|
||||
pub async fn wire(
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
) -> anyhow::Result<ActivityPubWire> {
|
||||
let concrete = std::sync::Arc::new(
|
||||
ActivityPubService::new(
|
||||
federation_repo,
|
||||
std::sync::Arc::new(DomainUserRepoAdapter(user_repo)),
|
||||
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
|
||||
std::sync::Arc::new(ReviewObjectHandler {
|
||||
movie_repository: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repository: diary_repo,
|
||||
@@ -44,6 +45,8 @@ pub async fn wire(
|
||||
base_url: base_url.clone(),
|
||||
}),
|
||||
base_url.clone(),
|
||||
allow_registration,
|
||||
"movies-diary".to_string(),
|
||||
cfg!(debug_assertions),
|
||||
)
|
||||
.await?,
|
||||
|
||||
@@ -5,6 +5,18 @@ use url::Url;
|
||||
|
||||
use domain::models::Review;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApHashtag {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
pub(crate) href: Url,
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_hashtag(title: &str) -> String {
|
||||
title.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewObject {
|
||||
@@ -22,6 +34,8 @@ pub struct ReviewObject {
|
||||
pub(crate) rating: u8,
|
||||
pub(crate) comment: Option<String>,
|
||||
pub(crate) watched_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub(crate) tag: Vec<ApHashtag>,
|
||||
}
|
||||
|
||||
/// Serialize a local Review into a ReviewObject for AP delivery.
|
||||
@@ -33,6 +47,7 @@ pub fn review_to_ap_object(
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
poster_url: Option<String>,
|
||||
base_url: &str,
|
||||
) -> ReviewObject {
|
||||
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
||||
let comment_text = review.comment().map(|c| c.value().to_string());
|
||||
@@ -50,6 +65,22 @@ pub fn review_to_ap_object(
|
||||
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
|
||||
};
|
||||
|
||||
let normalized = normalize_hashtag(&movie_title);
|
||||
let tag = vec![
|
||||
ApHashtag {
|
||||
kind: "Hashtag".to_string(),
|
||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
|
||||
.expect("valid base_url"),
|
||||
name: "#MoviesDiary".to_string(),
|
||||
},
|
||||
ApHashtag {
|
||||
kind: "Hashtag".to_string(),
|
||||
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
|
||||
.expect("valid base_url"),
|
||||
name: format!("#{}", normalized),
|
||||
},
|
||||
];
|
||||
|
||||
ReviewObject {
|
||||
kind: NoteType::default(),
|
||||
id: ap_id,
|
||||
@@ -62,5 +93,51 @@ pub fn review_to_ap_object(
|
||||
rating: review.rating().value(),
|
||||
comment: comment_text,
|
||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||
tag,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
||||
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_includes_two_hashtags() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(
|
||||
ReviewId::generate(),
|
||||
MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
ReviewSource::Local,
|
||||
);
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
"https://example.com/reviews/1".parse().unwrap(),
|
||||
"https://example.com/users/1".parse().unwrap(),
|
||||
"Dune".to_string(),
|
||||
2021,
|
||||
None,
|
||||
"https://example.com",
|
||||
);
|
||||
assert_eq!(obj.tag.len(), 2);
|
||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"#MoviesDiary"));
|
||||
assert!(names.contains(&"#Dune"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use activitypub_base::{ActivityPubService, RemoteActor};
|
||||
use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ActivityPubPort: Send + Sync {
|
||||
@@ -25,6 +25,12 @@ pub trait ActivityPubPort: Send + Sync {
|
||||
async fn get_accepted_followers(&self, local_user_id: Uuid)
|
||||
-> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -73,6 +79,24 @@ impl ActivityPubPort for ActivityPubService {
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.remove_follower(local_user_id, actor_url).await
|
||||
}
|
||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.block_actor(local_user_id, actor_url).await
|
||||
}
|
||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.unblock_actor(local_user_id, actor_url).await
|
||||
}
|
||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_blocked_actors(local_user_id).await
|
||||
}
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
||||
self.add_blocked_domain(domain, reason).await
|
||||
}
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
||||
self.remove_blocked_domain(domain).await
|
||||
}
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
self.get_blocked_domains().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopActivityPubService;
|
||||
@@ -112,4 +136,22 @@ impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
@@ -68,6 +68,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
results.push((ap_id, json));
|
||||
@@ -122,7 +123,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
@@ -131,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
results.push((ap_id, json, published));
|
||||
@@ -235,4 +237,11 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
||||
self.review_store.delete_by_actor(actor_url.as_str()).await
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.diary_repository
|
||||
.count_local_posts()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,49 @@ use std::sync::Arc;
|
||||
use activitypub_base::{ApUser, ApUserRepository};
|
||||
use async_trait::async_trait;
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
use url::Url;
|
||||
|
||||
pub struct DomainUserRepoAdapter(pub Arc<dyn UserRepository>);
|
||||
pub struct DomainUserRepoAdapter {
|
||||
pub repo: Arc<dyn UserRepository>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl DomainUserRepoAdapter {
|
||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
||||
Self { repo, base_url }
|
||||
}
|
||||
|
||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
||||
let avatar_url = u.avatar_path().and_then(|p| {
|
||||
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
|
||||
});
|
||||
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
||||
ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
bio: u.bio().map(|s| s.to_string()),
|
||||
avatar_url,
|
||||
profile_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApUserRepository for DomainUserRepoAdapter {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
||||
let user_id = UserId::from_uuid(id);
|
||||
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
bio: u.bio().map(|s| s.to_string()),
|
||||
avatar_path: u.avatar_path().map(|s| s.to_string()),
|
||||
}))
|
||||
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
||||
use domain::value_objects::Username;
|
||||
let uname =
|
||||
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
bio: u.bio().map(|s| s.to_string()),
|
||||
avatar_path: u.avatar_path().map(|s| s.to_string()),
|
||||
}))
|
||||
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> anyhow::Result<usize> {
|
||||
Ok(self.repo.list_with_stats().await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?
|
||||
.len())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use sqlx::{PgPool, Row};
|
||||
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource};
|
||||
|
||||
@@ -381,6 +381,106 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
}
|
||||
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let ts = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason",
|
||||
)
|
||||
.bind(domain)
|
||||
.bind(reason)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.get("domain"),
|
||||
reason: r.get("reason"),
|
||||
blocked_at: r.get("blocked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocked_domains WHERE domain = $1",
|
||||
)
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE blocked_domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
blocked_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE blocked_actors (
|
||||
local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
blocked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
);
|
||||
@@ -766,6 +766,16 @@ impl DiaryRepository for PostgresRepository {
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL"
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -5,7 +5,7 @@ use sqlx::{Row, SqlitePool};
|
||||
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource};
|
||||
|
||||
@@ -428,6 +428,105 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
}
|
||||
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let ts = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason",
|
||||
)
|
||||
.bind(domain)
|
||||
.bind(reason)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1")
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.get("domain"),
|
||||
reason: r.get("reason"),
|
||||
blocked_at: r.get("blocked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1",
|
||||
)
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content-specific repository (movies-diary) ---
|
||||
@@ -586,6 +685,84 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod actor_block_tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))")
|
||||
.execute(&pool).await.unwrap();
|
||||
let uid = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01")
|
||||
.execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_and_check_actor() {
|
||||
let pool = test_pool().await;
|
||||
let user_id = uuid::Uuid::parse_str(
|
||||
&sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1")
|
||||
.fetch_one(&pool).await.unwrap()
|
||||
).unwrap();
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
let actor_url = "https://mastodon.social/users/alice";
|
||||
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());
|
||||
repo.add_blocked_actor(user_id, actor_url).await.unwrap();
|
||||
assert!(repo.is_actor_blocked(user_id, actor_url).await.unwrap());
|
||||
let list = repo.get_blocked_actors(user_id).await.unwrap();
|
||||
assert_eq!(list, vec![actor_url.to_string()]);
|
||||
repo.remove_blocked_actor(user_id, actor_url).await.unwrap();
|
||||
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod domain_block_tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE blocked_domains (domain TEXT PRIMARY KEY, reason TEXT, blocked_at TEXT NOT NULL)")
|
||||
.execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_domain_is_detected() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap());
|
||||
repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap();
|
||||
assert!(repo.is_domain_blocked("mastodon.social").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_unblocks_domain() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
repo.add_blocked_domain("spam.xyz", None).await.unwrap();
|
||||
repo.remove_blocked_domain("spam.xyz").await.unwrap();
|
||||
assert!(!repo.is_domain_blocked("spam.xyz").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_blocked_domains_returns_all() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap();
|
||||
repo.add_blocked_domain("b.com", None).await.unwrap();
|
||||
let domains = repo.get_blocked_domains().await.unwrap();
|
||||
assert_eq!(domains.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE blocked_domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
blocked_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE blocked_actors (
|
||||
local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
blocked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
);
|
||||
@@ -1,492 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor};
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
|
||||
use crate::models::datetime_to_str;
|
||||
|
||||
pub struct SqliteFederationRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteFederationRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_str(status: &FollowerStatus) -> &'static str {
|
||||
match status {
|
||||
FollowerStatus::Pending => "pending",
|
||||
FollowerStatus::Accepted => "accepted",
|
||||
FollowerStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
fn str_to_status(s: &str) -> FollowerStatus {
|
||||
match s {
|
||||
"accepted" => FollowerStatus::Accepted,
|
||||
"rejected" => FollowerStatus::Rejected,
|
||||
_ => FollowerStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FederationRepository for SqliteFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
|
||||
sqlx::query(
|
||||
"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: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.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: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?")
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let followers = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let url: String = row.get("remote_actor_url");
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
|
||||
Follower {
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(followers)
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
|
||||
self.upsert_remote_actor(actor.clone()).await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&actor.url)
|
||||
.bind(follow_activity_id)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_following 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_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle = excluded.handle,
|
||||
inbox_url = excluded.inbox_url,
|
||||
shared_inbox_url = excluded.shared_inbox_url,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
fetched_at = excluded.fetched_at",
|
||||
)
|
||||
.bind(&actor.url)
|
||||
.bind(&actor.handle)
|
||||
.bind(&actor.inbox_url)
|
||||
.bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.avatar_url)
|
||||
.bind(&fetched_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> {
|
||||
let uid = user_id.to_string();
|
||||
let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
|
||||
}
|
||||
|
||||
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
public_key = excluded.public_key,
|
||||
private_key = excluded.private_key",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&public_key)
|
||||
.bind(&private_key)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row| RemoteActor {
|
||||
url: row.get("remote_actor_url"),
|
||||
handle: row.try_get("handle").unwrap_or_default(),
|
||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = match status {
|
||||
FollowingStatus::Pending => "pending",
|
||||
FollowingStatus::Accepted => "accepted",
|
||||
};
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()> {
|
||||
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.bind(object_url)
|
||||
.bind(actor_url)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content-specific repository (movies-diary) ---
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let actor_url = match review.source() {
|
||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||
ReviewSource::Local => {
|
||||
return Err(anyhow!("save_remote_review called with a local review"));
|
||||
}
|
||||
};
|
||||
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, NULL, ?, ?, NULL, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(movie_title)
|
||||
.bind(release_year.max(1888) as i64)
|
||||
.bind(poster_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let id = review.id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
.bind(&user_id)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
watched_at: chrono::NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?
|
||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(rating as i64)
|
||||
.bind(comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -752,6 +752,16 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL"
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1080,3 +1090,48 @@ mod feed_filter_tests {
|
||||
assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diary_count_tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_local_posts_excludes_remote_reviews() {
|
||||
use domain::ports::DiaryRepository;
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteMovieRepository::new(pool.clone());
|
||||
|
||||
let user_id = uuid::Uuid::new_v4().to_string();
|
||||
let movie_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO users (id, email, password_hash, created_at, username) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id).bind("Test Movie").bind(2024i32)
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
// Local review (remote_actor_url IS NULL)
|
||||
let r1 = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(&r1).bind(&movie_id).bind(&user_id).bind(4i32)
|
||||
.bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
// Remote review (remote_actor_url IS NOT NULL)
|
||||
let r2 = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(&r2).bind(&movie_id).bind(&user_id).bind(3i32)
|
||||
.bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00").bind("https://remote/user")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
let count = repo.count_local_posts().await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use application::ports::{
|
||||
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry,
|
||||
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
|
||||
@@ -224,6 +225,20 @@ struct FollowersTemplate {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blocked_domains.html")]
|
||||
struct BlockedDomainsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
domains: &'a [BlockedDomainEntry],
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blocked_actors.html")]
|
||||
struct BlockedActorsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
actors: &'a [BlockedActorEntry],
|
||||
}
|
||||
|
||||
struct HeatmapCell {
|
||||
month_label: String,
|
||||
count: i64,
|
||||
@@ -672,4 +687,22 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String> {
|
||||
BlockedDomainsTemplate {
|
||||
ctx: &data.ctx,
|
||||
domains: &data.domains,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String> {
|
||||
BlockedActorsTemplate {
|
||||
ctx: &data.ctx,
|
||||
actors: &data.actors,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Blocked Users</h2>
|
||||
|
||||
{% if actors.is_empty() %}
|
||||
<p>No users blocked.</p>
|
||||
{% else %}
|
||||
<ul class="following-list">
|
||||
{% for a in actors %}
|
||||
<li class="following-item">
|
||||
{% if let Some(avatar) = a.avatar_url %}
|
||||
<img src="{{ avatar }}" alt="avatar" style="width:32px;height:32px;border-radius:50%" />
|
||||
{% endif %}
|
||||
<strong>{{ a.handle }}</strong>{% if let Some(name) = a.display_name %} ({{ name }}){% endif %}
|
||||
<form method="POST" action="/social/unblock" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
|
||||
<input type="hidden" name="actor_url" value="{{ a.url }}" />
|
||||
<button type="submit">Unblock</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Blocked Domains</h2>
|
||||
|
||||
<form method="POST" action="/admin/blocked-domains">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
|
||||
<div>
|
||||
<label for="domain">Domain</label>
|
||||
<input id="domain" type="text" name="domain" placeholder="spam.example.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="reason">Reason (optional)</label>
|
||||
<input id="reason" type="text" name="reason" />
|
||||
</div>
|
||||
<button type="submit">Block Domain</button>
|
||||
</form>
|
||||
|
||||
{% if domains.is_empty() %}
|
||||
<p>No domains blocked.</p>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for d in domains %}
|
||||
<li>
|
||||
<strong>{{ d.domain }}</strong>{% if let Some(r) = d.reason %} — {{ r }}{% endif %}
|
||||
({{ d.blocked_at }})
|
||||
<form method="POST" action="/admin/blocked-domains/remove" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
|
||||
<input type="hidden" name="domain" value="{{ d.domain }}" />
|
||||
<button type="submit">Unblock</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,11 @@
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
<form method="POST" action="/social/block" style="display:inline">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Block</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Unfollow</button>
|
||||
</form>
|
||||
<form method="POST" action="/social/block" style="display:inline">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Block</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -152,6 +152,29 @@ pub struct ProfileSettingsPageData {
|
||||
pub saved: bool,
|
||||
}
|
||||
|
||||
pub struct BlockedDomainEntry {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
pub struct BlockedDomainsPageData {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub domains: Vec<BlockedDomainEntry>,
|
||||
}
|
||||
|
||||
pub struct BlockedActorEntry {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct BlockedActorsPageData {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub actors: Vec<BlockedActorEntry>,
|
||||
}
|
||||
|
||||
pub trait HtmlRenderer: Send + Sync {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
@@ -174,6 +197,8 @@ pub trait HtmlRenderer: Send + Sync {
|
||||
&self,
|
||||
data: ProfileSettingsPageData,
|
||||
) -> Result<String, String>;
|
||||
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
|
||||
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
|
||||
@@ -116,6 +116,7 @@ pub trait DiaryRepository: Send + Sync {
|
||||
movie_id: &MovieId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -278,6 +278,29 @@ pub struct FollowerActionForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BlockDomainForm {
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveDomainForm {
|
||||
pub domain: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActorUrlForm {
|
||||
pub actor_url: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct ProfileQueryParams {
|
||||
pub view: Option<String>,
|
||||
@@ -472,6 +495,27 @@ pub struct MovieDetailResponse {
|
||||
pub reviews: SocialFeedResponse,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct BlockedDomainResponse {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct AddBlockedDomainRequest {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct BlockedActorResponse {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -232,6 +232,9 @@ mod tests {
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
@@ -440,6 +443,8 @@ mod tests {
|
||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
|
||||
@@ -411,6 +411,97 @@ fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_domains_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let response: Vec<crate::dtos::BlockedDomainResponse> = domains
|
||||
.into_iter()
|
||||
.map(|d| crate::dtos::BlockedDomainResponse {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect();
|
||||
axum::Json(response).into_response()
|
||||
}
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn add_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
axum::Json(body): axum::Json<crate::dtos::AddBlockedDomainRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
|
||||
Ok(()) => StatusCode::CREATED.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn remove_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
axum::extract::Path(domain): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.remove_blocked_domain(&domain).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn block_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.block_actor(user.0.value(), &body.actor_url).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn unblock_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_actors_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_blocked_actors(user.0.value()).await {
|
||||
Ok(actors) => {
|
||||
let response: Vec<crate::dtos::BlockedActorResponse> = actors
|
||||
.into_iter()
|
||||
.map(|a| crate::dtos::BlockedActorResponse {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
axum::Json(response).into_response()
|
||||
}
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn ap_err(e: anyhow::Error) -> impl IntoResponse {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
|
||||
@@ -10,7 +10,10 @@ use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use application::ports::{FollowersPageData, FollowingPageData};
|
||||
use application::ports::{
|
||||
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
|
||||
FollowersPageData, FollowingPageData,
|
||||
};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||
ports::{
|
||||
@@ -27,13 +30,13 @@ use domain::models::ExportFormat;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm};
|
||||
use crate::dtos::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
dtos::{
|
||||
ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm,
|
||||
},
|
||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||
extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -1019,6 +1022,160 @@ pub async fn get_profile_settings(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
|
||||
if tag.eq_ignore_ascii_case("moviesdiary") {
|
||||
Redirect::temporary("/")
|
||||
} else {
|
||||
Redirect::temporary(&format!("/?search={}", tag))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_domains_page(
|
||||
AdminUser(user_id): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
|
||||
match state.ap_service.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let data = BlockedDomainsPageData {
|
||||
ctx,
|
||||
domains: domains
|
||||
.into_iter()
|
||||
.map(|d| BlockedDomainEntry {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
match state.html_renderer.render_blocked_domains_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_domains error: {:?}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_blocked_domain(
|
||||
AdminUser(_): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<BlockDomainForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
|
||||
match state.ap_service.add_blocked_domain(&form.domain, reason).await {
|
||||
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("add_blocked_domain error: {:?}", e);
|
||||
Redirect::to("/admin/blocked-domains").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_remove_blocked_domain(
|
||||
AdminUser(_): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<RemoveDomainForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.remove_blocked_domain(&form.domain).await {
|
||||
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("remove_blocked_domain error: {:?}", e);
|
||||
Redirect::to("/admin/blocked-domains").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_actors_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Blocked Users — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
|
||||
match state.ap_service.get_blocked_actors(user_id.value()).await {
|
||||
Ok(actors) => {
|
||||
let data = BlockedActorsPageData {
|
||||
ctx,
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| BlockedActorEntry {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
match state.html_renderer.render_blocked_actors_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_actors error: {:?}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_block_actor_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<ActorUrlForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.block_actor(user_id.value(), &form.actor_url).await {
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("block_actor html error: {:?}", e);
|
||||
Redirect::to("/social/blocked").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_unblock_actor(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<ActorUrlForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await {
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("unblock_actor error: {:?}", e);
|
||||
Redirect::to("/social/blocked").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_profile_settings(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
Arc::clone(&review_repository),
|
||||
Arc::clone(&diary_repository),
|
||||
app_config.base_url.clone(),
|
||||
app_config.allow_registration,
|
||||
).await?;
|
||||
let ap_router = ap.router;
|
||||
let ap_service_arc = ap.service;
|
||||
|
||||
@@ -100,7 +100,8 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/settings/profile",
|
||||
routing::get(handlers::html::get_profile_settings)
|
||||
.post(handlers::html::post_profile_settings),
|
||||
);
|
||||
)
|
||||
.route("/tags/{tag}", routing::get(handlers::html::get_tag));
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_html_routes());
|
||||
@@ -139,6 +140,21 @@ fn federation_html_routes() -> Router<AppState> {
|
||||
"/users/{id}/followers/remove",
|
||||
routing::post(handlers::html::remove_follower),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
routing::get(handlers::html::get_blocked_domains_page)
|
||||
.post(handlers::html::post_blocked_domain),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains/remove",
|
||||
routing::post(handlers::html::post_remove_blocked_domain),
|
||||
)
|
||||
.route(
|
||||
"/social/blocked",
|
||||
routing::get(handlers::html::get_blocked_actors_page),
|
||||
)
|
||||
.route("/social/block", routing::post(handlers::html::post_block_actor_html))
|
||||
.route("/social/unblock", routing::post(handlers::html::post_unblock_actor))
|
||||
}
|
||||
|
||||
fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
@@ -220,4 +236,16 @@ fn federation_api_routes() -> Router<AppState> {
|
||||
"/social/followers/remove",
|
||||
routing::post(handlers::api::remove_follower),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
routing::get(handlers::api::get_blocked_domains_admin)
|
||||
.post(handlers::api::add_blocked_domain_admin),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains/{domain}",
|
||||
routing::delete(handlers::api::remove_blocked_domain_admin),
|
||||
)
|
||||
.route("/social/block", routing::post(handlers::api::block_actor_api))
|
||||
.route("/social/unblock", routing::post(handlers::api::unblock_actor_api))
|
||||
.route("/social/blocked", routing::get(handlers::api::get_blocked_actors_api))
|
||||
}
|
||||
|
||||
@@ -302,6 +302,38 @@ async fn get_api_movie_detail_returns_404_for_unknown_id() {
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_moviesdiary_redirects_to_home() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/moviesdiary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_other_redirects_to_search() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/batman")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/?search=batman");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_movie_detail_html_returns_404_for_unknown_id() {
|
||||
let app = test_app().await;
|
||||
|
||||
Reference in New Issue
Block a user