Compare commits
23 Commits
master
...
636d3d453d
| Author | SHA1 | Date | |
|---|---|---|---|
| 636d3d453d | |||
| 9172c82d54 | |||
| cd2eb48ddb | |||
| c5d9833c8b | |||
| f39c1a614d | |||
| 30c8a17168 | |||
| 6a8c8b1fb8 | |||
| 4ec0725ff8 | |||
| 31e0f2958c | |||
| 555121ea75 | |||
| 9e795eefdc | |||
| 18cf2c9f54 | |||
| b58c96b843 | |||
| 8ea24461ba | |||
| e14a9f90c8 | |||
| 28756ef4cd | |||
| 7f27ae49c3 | |||
| 59f3423c00 | |||
| c48aa33592 | |||
| 8f3aa4b891 | |||
| 32bfb00970 | |||
| 7ce2901c2a | |||
| 8bbc713093 |
18
.claude/settings.json
Normal file
18
.claude/settings.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"if": "Bash(git commit*)",
|
||||||
|
"command": "cargo fmt --all 2>&1 && cargo clippy --workspace 2>&1 || echo '{\"continue\": false, \"stopReason\": \"cargo fmt or clippy failed — fix before committing\"}'",
|
||||||
|
"timeout": 120,
|
||||||
|
"statusMessage": "Running cargo fmt + clippy..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Cargo.lock
generated
51
Cargo.lock
generated
@@ -5,22 +5,6 @@ version = 4
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "activitypub"
|
name = "activitypub"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
|
||||||
"activitypub-base",
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"chrono",
|
|
||||||
"domain",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
"url",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "activitypub-base"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -28,8 +12,8 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"enum_delegate",
|
|
||||||
"futures",
|
"futures",
|
||||||
|
"k-ap",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -289,7 +273,7 @@ dependencies = [
|
|||||||
name = "application"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub-base",
|
"activitypub",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -596,7 +580,6 @@ name = "bootstrap"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"activitypub-base",
|
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auth",
|
"auth",
|
||||||
@@ -605,6 +588,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"event-transport",
|
"event-transport",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
|
"k-ap",
|
||||||
"nats",
|
"nats",
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-federation",
|
"postgres-federation",
|
||||||
@@ -2005,6 +1989,27 @@ dependencies = [
|
|||||||
"simple_asn1",
|
"simple_asn1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "k-ap"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.2#767b1e69d4f384093ea33d72d5aa46ff140f5ac8"
|
||||||
|
dependencies = [
|
||||||
|
"activitypub_federation",
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"chrono",
|
||||||
|
"enum_delegate",
|
||||||
|
"futures",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "language-tags"
|
name = "language-tags"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2452,7 +2457,7 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|||||||
name = "postgres"
|
name = "postgres"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub-base",
|
"activitypub",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -2470,10 +2475,10 @@ dependencies = [
|
|||||||
name = "postgres-federation"
|
name = "postgres-federation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub-base",
|
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"k-ap",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2522,7 +2527,7 @@ dependencies = [
|
|||||||
name = "presentation"
|
name = "presentation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub-base",
|
"activitypub",
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4715,7 +4720,6 @@ name = "worker"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"activitypub-base",
|
|
||||||
"application",
|
"application",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -4723,6 +4727,7 @@ dependencies = [
|
|||||||
"event-payload",
|
"event-payload",
|
||||||
"event-transport",
|
"event-transport",
|
||||||
"futures",
|
"futures",
|
||||||
|
"k-ap",
|
||||||
"nats",
|
"nats",
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-federation",
|
"postgres-federation",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ members = [
|
|||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/postgres-search",
|
"crates/adapters/postgres-search",
|
||||||
"crates/adapters/postgres-federation",
|
"crates/adapters/postgres-federation",
|
||||||
"crates/adapters/activitypub-base",
|
|
||||||
"crates/adapters/activitypub",
|
"crates/adapters/activitypub",
|
||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/nats",
|
"crates/adapters/nats",
|
||||||
@@ -46,7 +45,6 @@ api-types = { path = "crates/api-types" }
|
|||||||
postgres = { path = "crates/adapters/postgres" }
|
postgres = { path = "crates/adapters/postgres" }
|
||||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||||
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
|
||||||
activitypub = { path = "crates/adapters/activitypub" }
|
activitypub = { path = "crates/adapters/activitypub" }
|
||||||
auth = { path = "crates/adapters/auth" }
|
auth = { path = "crates/adapters/auth" }
|
||||||
nats = { path = "crates/adapters/nats" }
|
nats = { path = "crates/adapters/nats" }
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "activitypub-base"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
axum = { workspace = true }
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
url = { workspace = true }
|
|
||||||
domain = { workspace = true }
|
|
||||||
|
|
||||||
activitypub_federation = "0.7.0-beta.11"
|
|
||||||
enum_delegate = "0.2"
|
|
||||||
@@ -1,851 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
config::Data,
|
|
||||||
fetch::object_id::ObjectId,
|
|
||||||
kinds::activity::{
|
|
||||||
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
|
|
||||||
},
|
|
||||||
traits::Activity,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename = "Announce")]
|
|
||||||
pub struct AnnounceType;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename = "Like")]
|
|
||||||
pub struct LikeType;
|
|
||||||
|
|
||||||
impl Default for LikeType {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::actors::DbActor;
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::repository::{FollowerStatus, FollowingStatus};
|
|
||||||
|
|
||||||
// --- Follow ---
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct FollowActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: FollowType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: ObjectId<DbActor>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for FollowActivity {
|
|
||||||
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> {
|
|
||||||
let target_url = self.object.inner();
|
|
||||||
let target_domain = match (target_url.host_str(), target_url.port()) {
|
|
||||||
(Some(host), Some(port)) => format!("{}:{}", host, port),
|
|
||||||
(Some(host), None) => host.to_string(),
|
|
||||||
_ => {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"invalid follow target URL"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if target_domain != data.domain {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"follow target is not a local actor"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
self.actor.inner().as_str(),
|
|
||||||
FollowerStatus::Pending,
|
|
||||||
self.id.as_str(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
follower = %self.actor.inner(),
|
|
||||||
local_user = %local_actor.user_id,
|
|
||||||
"follow request pending approval"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Accept ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AcceptActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: AcceptType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: FollowActivity,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for AcceptActivity {
|
|
||||||
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> {
|
|
||||||
if self.actor.inner() != self.object.object.inner() {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Accept actor does not match Follow target"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
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
|
|
||||||
.update_following_status(
|
|
||||||
local_user_id,
|
|
||||||
self.actor.inner().as_str(),
|
|
||||||
FollowingStatus::Accepted,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Reject ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RejectActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: RejectType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: FollowActivity,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for RejectActivity {
|
|
||||||
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> {
|
|
||||||
if self.actor.inner() != self.object.object.inner() {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Reject actor does not match Follow target"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
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())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tracing::info!(actor = %self.actor.inner(), "follow rejected");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Undo ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UndoActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: UndoType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for UndoActivity {
|
|
||||||
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 Undo from blocked domain");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let obj_type = self
|
|
||||||
.object
|
|
||||||
.get("type")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
match obj_type {
|
|
||||||
"Follow" => {
|
|
||||||
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
|
|
||||||
&& let Ok(url) = Url::parse(obj_url)
|
|
||||||
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
|
|
||||||
{
|
|
||||||
data.federation_repo
|
|
||||||
.remove_follower(user_id, self.actor.inner().as_str())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
data.object_handler
|
|
||||||
.on_actor_removed(self.actor.inner())
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
|
||||||
}
|
|
||||||
"Add" => {
|
|
||||||
let ap_id_str = self
|
|
||||||
.object
|
|
||||||
.get("object")
|
|
||||||
.and_then(|o| o.get("id"))
|
|
||||||
.and_then(|id| id.as_str())
|
|
||||||
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
|
|
||||||
|
|
||||||
if let Some(ap_id_str) = ap_id_str
|
|
||||||
&& let Ok(ap_id) = Url::parse(ap_id_str)
|
|
||||||
{
|
|
||||||
data.object_handler
|
|
||||||
.on_delete(&ap_id, self.actor.inner())
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"Like" => {
|
|
||||||
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
|
|
||||||
&& let Ok(obj_url) = Url::parse(obj_url_str)
|
|
||||||
&& obj_url.host_str().unwrap_or("") == data.domain
|
|
||||||
{
|
|
||||||
data.object_handler
|
|
||||||
.on_unlike(&obj_url, self.actor.inner())
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(error = %e, "failed to process unlike");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Create ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CreateActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: CreateType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: serde_json::Value,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) bto: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) bcc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for CreateActivity {
|
|
||||||
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> {
|
|
||||||
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
|
|
||||||
&& let Ok(attributed_url) = Url::parse(attributed_to)
|
|
||||||
&& &attributed_url != self.actor.inner()
|
|
||||||
{
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Create actor does not match object attributedTo"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
// Use the Note's own id, not the Create activity id (which ends in /activity).
|
|
||||||
// Delete activities reference the Note id, so they must match.
|
|
||||||
let ap_id = self
|
|
||||||
.object
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| Url::parse(s).ok())
|
|
||||||
.unwrap_or_else(|| self.id.clone());
|
|
||||||
let actor_url = self.actor.inner().clone();
|
|
||||||
data.object_handler
|
|
||||||
.on_create(&ap_id, &actor_url, self.object)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %actor_url, "received create activity");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delete ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DeleteActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: DeleteType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: serde_json::Value,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for DeleteActivity {
|
|
||||||
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> {
|
|
||||||
let actor_domain = self.actor.inner().host_str().unwrap_or("");
|
|
||||||
let object_domain = match &self.object {
|
|
||||||
serde_json::Value::String(s) => Url::parse(s)
|
|
||||||
.ok()
|
|
||||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
serde_json::Value::Object(o) => o
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| Url::parse(s).ok())
|
|
||||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
if !object_domain.is_empty() && actor_domain != object_domain {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Delete actor domain does not match object domain"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
let actor_url = self.actor.inner().clone();
|
|
||||||
|
|
||||||
// Extract object URL — handles plain string and Tombstone {"id":"...","type":"Tombstone"}
|
|
||||||
let object_url_str = match &self.object {
|
|
||||||
serde_json::Value::String(s) => s.clone(),
|
|
||||||
serde_json::Value::Object(o) => o
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
let Ok(object_url) = Url::parse(&object_url_str) else {
|
|
||||||
tracing::warn!(actor = %actor_url, "Delete activity has unparseable object, ignoring");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actor self-deletion: Mastodon sends Delete(actor_url) when an account is deleted.
|
|
||||||
if object_url == *self.actor.inner() {
|
|
||||||
data.object_handler
|
|
||||||
.on_actor_removed(&actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal note deletion.
|
|
||||||
data.object_handler
|
|
||||||
.on_delete(&object_url, &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(object = %object_url, "received Delete(note)");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: UpdateType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: serde_json::Value,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for UpdateActivity {
|
|
||||||
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> {
|
|
||||||
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
|
|
||||||
&& let Ok(attributed_url) = Url::parse(attributed_to)
|
|
||||||
&& &attributed_url != self.actor.inner()
|
|
||||||
{
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Update actor does not match object attributedTo"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
let ap_id = self
|
|
||||||
.object
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| Url::parse(s).ok())
|
|
||||||
.unwrap_or_else(|| self.id.clone());
|
|
||||||
let actor_url = self.actor.inner().clone();
|
|
||||||
data.object_handler
|
|
||||||
.on_update(&ap_id, &actor_url, self.object)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %actor_url, "received update activity");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Announce ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AnnounceActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: AnnounceType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: Url,
|
|
||||||
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for AnnounceActivity {
|
|
||||||
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(());
|
|
||||||
}
|
|
||||||
let object_domain = self.object.host_str().unwrap_or("");
|
|
||||||
if object_domain != data.domain {
|
|
||||||
tracing::debug!(
|
|
||||||
actor = %self.actor.inner(),
|
|
||||||
object = %self.object,
|
|
||||||
"received Announce of non-local object — skipped (cross-server boost not supported)"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
data.federation_repo
|
|
||||||
.add_announce(
|
|
||||||
self.id.as_str(),
|
|
||||||
self.object.as_str(),
|
|
||||||
self.actor.inner().as_str(),
|
|
||||||
self.published.unwrap_or_else(chrono::Utc::now),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
data.object_handler
|
|
||||||
.on_announce_received(&self.object, self.actor.inner())
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(error = %e, "failed to process announce notification");
|
|
||||||
});
|
|
||||||
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Like ---
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct LikeActivity {
|
|
||||||
pub id: Url,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: LikeType,
|
|
||||||
pub actor: ObjectId<DbActor>,
|
|
||||||
pub object: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for LikeActivity {
|
|
||||||
type DataType = FederationData;
|
|
||||||
type Error = crate::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 Like from blocked domain");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process if the liked object is on our instance.
|
|
||||||
if self.object.host_str().unwrap_or("") != data.domain {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
data.object_handler
|
|
||||||
.on_like(&self.object, self.actor.inner())
|
|
||||||
.await
|
|
||||||
.map_err(|e| crate::error::Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
|
|
||||||
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add ---
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename = "Add")]
|
|
||||||
pub struct AddType;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AddActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: AddType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: serde_json::Value,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for AddActivity {
|
|
||||||
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 Add from blocked domain");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let ap_id = self.id.clone();
|
|
||||||
let actor_url = self.actor.inner().clone();
|
|
||||||
data.object_handler
|
|
||||||
.on_create(&ap_id, &actor_url, self.object)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %actor_url, "received Add activity");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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(());
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
let _ = data
|
|
||||||
.federation_repo
|
|
||||||
.remove_follower(local_user_id, self.actor.inner().as_str())
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
tracing::info!(actor = %self.actor.inner(), "received block — removed following and follower");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Move (account migration) ---
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename = "Move")]
|
|
||||||
pub struct MoveType;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct MoveActivity {
|
|
||||||
pub(crate) id: Url,
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub(crate) kind: MoveType,
|
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
|
||||||
pub(crate) object: Url,
|
|
||||||
pub(crate) target: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Activity for MoveActivity {
|
|
||||||
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> {
|
|
||||||
if &self.object != self.actor.inner() {
|
|
||||||
return Err(Error::bad_request(anyhow::anyhow!(
|
|
||||||
"Move object must be the actor itself"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
actor = %self.actor.inner(),
|
|
||||||
target = %self.target,
|
|
||||||
"received Move (account migration) — target noted"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Inbox dispatch enum ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type")]
|
|
||||||
#[enum_delegate::implement(Activity)]
|
|
||||||
pub enum InboxActivities {
|
|
||||||
#[serde(rename = "Follow")]
|
|
||||||
Follow(FollowActivity),
|
|
||||||
#[serde(rename = "Accept")]
|
|
||||||
Accept(AcceptActivity),
|
|
||||||
#[serde(rename = "Reject")]
|
|
||||||
Reject(RejectActivity),
|
|
||||||
#[serde(rename = "Undo")]
|
|
||||||
Undo(UndoActivity),
|
|
||||||
#[serde(rename = "Create")]
|
|
||||||
Create(CreateActivity),
|
|
||||||
#[serde(rename = "Delete")]
|
|
||||||
Delete(DeleteActivity),
|
|
||||||
#[serde(rename = "Update")]
|
|
||||||
Update(UpdateActivity),
|
|
||||||
#[serde(rename = "Announce")]
|
|
||||||
Announce(AnnounceActivity),
|
|
||||||
#[serde(rename = "Add")]
|
|
||||||
Add(AddActivity),
|
|
||||||
#[serde(rename = "Block")]
|
|
||||||
Block(BlockActivity),
|
|
||||||
#[serde(rename = "Like")]
|
|
||||||
Like(LikeActivity),
|
|
||||||
#[serde(rename = "Move")]
|
|
||||||
Move(MoveActivity),
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
|
||||||
};
|
|
||||||
use axum::extract::Path;
|
|
||||||
|
|
||||||
use crate::actors::{Person, get_local_actor};
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub async fn actor_handler(
|
|
||||||
Path(username): Path<String>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
|
||||||
let ap_user = data
|
|
||||||
.user_repo
|
|
||||||
.find_by_username(&username)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let db_actor = get_local_actor(ap_user.id, &data).await?;
|
|
||||||
let person = db_actor.into_json(&data).await?;
|
|
||||||
|
|
||||||
Ok(FederationJson(WithContext::new_default(person)))
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
config::Data,
|
|
||||||
fetch::object_id::ObjectId,
|
|
||||||
http_signatures::generate_actor_keypair,
|
|
||||||
kinds::actor::PersonType,
|
|
||||||
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
|
||||||
traits::{Actor, Object},
|
|
||||||
};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::repository::RemoteActor;
|
|
||||||
use crate::user::ApProfileField;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DbActor {
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub public_key_pem: String,
|
|
||||||
pub private_key_pem: Option<String>,
|
|
||||||
pub inbox_url: Url,
|
|
||||||
pub shared_inbox_url: Option<Url>,
|
|
||||||
pub outbox_url: Url,
|
|
||||||
pub followers_url: Url,
|
|
||||||
pub following_url: Url,
|
|
||||||
pub ap_id: Url,
|
|
||||||
pub last_refreshed_at: DateTime<Utc>,
|
|
||||||
pub bio: Option<String>,
|
|
||||||
pub avatar_url: Option<Url>,
|
|
||||||
pub banner_url: Option<Url>,
|
|
||||||
pub also_known_as: Option<String>,
|
|
||||||
pub profile_url: Option<Url>,
|
|
||||||
pub attachment: Vec<ApProfileField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ApImageObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: String,
|
|
||||||
pub url: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Endpoints {
|
|
||||||
pub shared_inbox: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ProfileFieldObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: String,
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Person {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: PersonType,
|
|
||||||
id: ObjectId<DbActor>,
|
|
||||||
preferred_username: String,
|
|
||||||
inbox: Url,
|
|
||||||
outbox: Url,
|
|
||||||
followers: Url,
|
|
||||||
following: Url,
|
|
||||||
public_key: PublicKey,
|
|
||||||
name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
summary: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
icon: Option<ApImageObject>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
url: Option<Url>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
discoverable: Option<bool>,
|
|
||||||
manually_approves_followers: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
||||||
updated: Option<DateTime<Utc>>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
endpoints: Option<Endpoints>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
image: Option<ApImageObject>,
|
|
||||||
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
also_known_as: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
attachment: Vec<ProfileFieldObject>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActorUrls {
|
|
||||||
ap_id: Url,
|
|
||||||
inbox_url: Url,
|
|
||||||
shared_inbox_url: Option<Url>,
|
|
||||||
outbox_url: Url,
|
|
||||||
followers_url: Url,
|
|
||||||
following_url: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActorUrls {
|
|
||||||
fn build(base_url: &str, user_id: uuid::Uuid) -> Self {
|
|
||||||
let ap_id = crate::urls::actor_url(base_url, user_id);
|
|
||||||
Self {
|
|
||||||
inbox_url: Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"),
|
|
||||||
shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(),
|
|
||||||
outbox_url: Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"),
|
|
||||||
followers_url: Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"),
|
|
||||||
following_url: Url::parse(&format!("{}/following", &ap_id)).expect("valid url"),
|
|
||||||
ap_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_local_actor(
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
data: &Data<FederationData>,
|
|
||||||
) -> Result<DbActor, Error> {
|
|
||||||
let user = data
|
|
||||||
.user_repo
|
|
||||||
.find_by_id(user_id)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
|
||||||
|
|
||||||
let (public_key, private_key) = match data
|
|
||||||
.federation_repo
|
|
||||||
.get_local_actor_keypair(user_id)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
Some(kp) => kp,
|
|
||||||
None => {
|
|
||||||
let kp = generate_actor_keypair()?;
|
|
||||||
data.federation_repo
|
|
||||||
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
|
||||||
.await?;
|
|
||||||
(kp.public_key, kp.private_key)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ActorUrls {
|
|
||||||
ap_id,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
} = ActorUrls::build(&data.base_url, user_id);
|
|
||||||
|
|
||||||
Ok(DbActor {
|
|
||||||
user_id,
|
|
||||||
username: user.username,
|
|
||||||
public_key_pem: public_key,
|
|
||||||
private_key_pem: Some(private_key),
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: user.bio,
|
|
||||||
avatar_url: user.avatar_url,
|
|
||||||
banner_url: user.banner_url,
|
|
||||||
also_known_as: user.also_known_as,
|
|
||||||
profile_url: user.profile_url,
|
|
||||||
attachment: user.attachment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Object for DbActor {
|
|
||||||
type DataType = FederationData;
|
|
||||||
type Kind = Person;
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn id(&self) -> &Url {
|
|
||||||
&self.ap_id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
|
||||||
Some(self.last_refreshed_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_from_id(
|
|
||||||
object_id: Url,
|
|
||||||
data: &Data<Self::DataType>,
|
|
||||||
) -> Result<Option<Self>, Self::Error> {
|
|
||||||
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let user = match data.user_repo.find_by_id(user_id).await {
|
|
||||||
Ok(Some(u)) => u,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let keypair = data
|
|
||||||
.federation_repo
|
|
||||||
.get_local_actor_keypair(user_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (public_key, private_key) = match keypair {
|
|
||||||
Some(kp) => (kp.0, Some(kp.1)),
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ActorUrls {
|
|
||||||
ap_id,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
} = ActorUrls::build(&data.base_url, user_id);
|
|
||||||
|
|
||||||
Ok(Some(DbActor {
|
|
||||||
user_id,
|
|
||||||
username: user.username,
|
|
||||||
public_key_pem: public_key,
|
|
||||||
private_key_pem: private_key,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: None,
|
|
||||||
avatar_url: None,
|
|
||||||
banner_url: None,
|
|
||||||
also_known_as: None,
|
|
||||||
profile_url: None,
|
|
||||||
attachment: vec![],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
|
||||||
let public_key = PublicKey {
|
|
||||||
id: format!("{}#main-key", &self.ap_id),
|
|
||||||
owner: self.ap_id.clone(),
|
|
||||||
public_key_pem: self.public_key_pem.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let icon = self.avatar_url.map(|url| ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
let image = self.banner_url.map(|url| ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
let profile_url = self.profile_url;
|
|
||||||
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
|
||||||
let attachment: Vec<ProfileFieldObject> = self
|
|
||||||
.attachment
|
|
||||||
.into_iter()
|
|
||||||
.map(|f| ProfileFieldObject {
|
|
||||||
kind: "PropertyValue".to_string(),
|
|
||||||
name: f.name,
|
|
||||||
value: f.value,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let shared_inbox =
|
|
||||||
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
|
|
||||||
|
|
||||||
Ok(Person {
|
|
||||||
kind: Default::default(),
|
|
||||||
id: self.ap_id.clone().into(),
|
|
||||||
preferred_username: self.username.clone(),
|
|
||||||
inbox: self.inbox_url.clone(),
|
|
||||||
outbox: self.outbox_url.clone(),
|
|
||||||
followers: self.followers_url.clone(),
|
|
||||||
following: self.following_url.clone(),
|
|
||||||
public_key,
|
|
||||||
name: Some(self.username.clone()),
|
|
||||||
summary: self.bio.clone(),
|
|
||||||
icon,
|
|
||||||
url: profile_url,
|
|
||||||
discoverable: Some(true),
|
|
||||||
manually_approves_followers: true,
|
|
||||||
updated: Some(self.last_refreshed_at),
|
|
||||||
endpoints: Some(Endpoints { shared_inbox }),
|
|
||||||
image,
|
|
||||||
also_known_as,
|
|
||||||
attachment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify(
|
|
||||||
json: &Self::Kind,
|
|
||||||
expected_domain: &Url,
|
|
||||||
_data: &Data<Self::DataType>,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
verify_domains_match(json.id.inner(), expected_domain)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
|
||||||
let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string());
|
|
||||||
let actor = RemoteActor {
|
|
||||||
url: json.id.inner().to_string(),
|
|
||||||
handle: json.preferred_username.clone(),
|
|
||||||
inbox_url: json.inbox.to_string(),
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name: json.name.clone(),
|
|
||||||
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
|
||||||
outbox_url: Some(json.outbox.to_string()),
|
|
||||||
};
|
|
||||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
|
||||||
|
|
||||||
let url_str = json.id.inner().to_string();
|
|
||||||
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
|
||||||
let ap_id = json.id.inner().clone();
|
|
||||||
let inbox_url = json.inbox.clone();
|
|
||||||
let shared_inbox_url = json
|
|
||||||
.endpoints
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
|
|
||||||
let outbox_url = json.outbox.clone();
|
|
||||||
let followers_url = json.followers.clone();
|
|
||||||
let following_url = json.following.clone();
|
|
||||||
|
|
||||||
Ok(DbActor {
|
|
||||||
user_id,
|
|
||||||
username: json.preferred_username.clone(),
|
|
||||||
public_key_pem: json.public_key.public_key_pem,
|
|
||||||
private_key_pem: None,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: json.summary.clone(),
|
|
||||||
avatar_url: json.icon.as_ref().map(|i| i.url.clone()),
|
|
||||||
banner_url: json.image.as_ref().map(|i| i.url.clone()),
|
|
||||||
also_known_as: json.also_known_as.into_iter().next(),
|
|
||||||
profile_url: json.url.clone(),
|
|
||||||
attachment: json
|
|
||||||
.attachment
|
|
||||||
.iter()
|
|
||||||
.map(|f| crate::user::ApProfileField {
|
|
||||||
name: f.name.clone(),
|
|
||||||
value: f.value.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Actor for DbActor {
|
|
||||||
fn public_key_pem(&self) -> &str {
|
|
||||||
&self.public_key_pem
|
|
||||||
}
|
|
||||||
|
|
||||||
fn private_key_pem(&self) -> Option<String> {
|
|
||||||
self.private_key_pem.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inbox(&self) -> Url {
|
|
||||||
self.inbox_url.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/actors.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ApObjectHandler: Send + Sync {
|
|
||||||
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
|
||||||
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
|
||||||
async fn get_local_objects_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
|
||||||
|
|
||||||
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
|
||||||
/// Returns (ap_id, object_json, published_at).
|
|
||||||
async fn get_local_objects_page(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
|
||||||
|
|
||||||
/// Incoming Create activity — persist remote content.
|
|
||||||
async fn on_create(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Incoming Update activity — update existing remote content.
|
|
||||||
async fn on_update(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Incoming Delete activity — remove specific remote content.
|
|
||||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Actor unfollowed/was removed — clean up all their remote content.
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Called when a remote actor likes a local thought.
|
|
||||||
/// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`).
|
|
||||||
/// `actor_url` is the AP URL of the remote actor who sent the Like.
|
|
||||||
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Called when a remote actor boosts (Announce) a local thought.
|
|
||||||
/// `object_url` is the AP URL of the announced note.
|
|
||||||
/// `actor_url` is the AP URL of the remote actor who sent the Announce.
|
|
||||||
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Called when a remote actor removes a Like from a local thought.
|
|
||||||
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Called when an inbound Note tags a local user with a Mention.
|
|
||||||
async fn on_mention(
|
|
||||||
&self,
|
|
||||||
thought_ap_id: &Url,
|
|
||||||
mentioned_user_uuid: uuid::Uuid,
|
|
||||||
actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Total number of locally-authored posts across all users.
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::content::ApObjectHandler;
|
|
||||||
use crate::repository::FederationRepository;
|
|
||||||
use crate::user::ApUserRepository;
|
|
||||||
use domain::ports::EventPublisher;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct FederationData {
|
|
||||||
pub(crate) federation_repo: Arc<dyn FederationRepository>,
|
|
||||||
pub(crate) user_repo: Arc<dyn ApUserRepository>,
|
|
||||||
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,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FederationData {
|
|
||||||
pub fn new(
|
|
||||||
federation_repo: Arc<dyn FederationRepository>,
|
|
||||||
user_repo: Arc<dyn ApUserRepository>,
|
|
||||||
object_handler: Arc<dyn ApObjectHandler>,
|
|
||||||
base_url: String,
|
|
||||||
allow_registration: bool,
|
|
||||||
software_name: String,
|
|
||||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
|
||||||
) -> Self {
|
|
||||||
let domain = base_url
|
|
||||||
.trim_start_matches("https://")
|
|
||||||
.trim_start_matches("http://")
|
|
||||||
.split('/')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
Self {
|
|
||||||
federation_repo,
|
|
||||||
user_repo,
|
|
||||||
object_handler,
|
|
||||||
base_url,
|
|
||||||
domain,
|
|
||||||
allow_registration,
|
|
||||||
software_name,
|
|
||||||
event_publisher,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use std::fmt::{Display, Formatter};
|
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
|
||||||
|
|
||||||
impl Error {
|
|
||||||
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
|
||||||
Self(e.into(), StatusCode::NOT_FOUND)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
|
||||||
Self(e.into(), StatusCode::BAD_REQUEST)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Error {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
std::fmt::Display::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> From<T> for Error
|
|
||||||
where
|
|
||||||
T: Into<anyhow::Error>,
|
|
||||||
{
|
|
||||||
fn from(t: T) -> Self {
|
|
||||||
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl axum::response::IntoResponse for Error {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
let status = self.1;
|
|
||||||
if status.is_server_error() {
|
|
||||||
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
|
|
||||||
} else {
|
|
||||||
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
|
||||||
}
|
|
||||||
let body = if status.is_server_error() {
|
|
||||||
"internal server error".to_string()
|
|
||||||
} else {
|
|
||||||
self.0.to_string()
|
|
||||||
};
|
|
||||||
(status, body).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
|
|
||||||
use activitypub_federation::error::Error as FedError;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct PermissiveVerifier;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl UrlVerifier for PermissiveVerifier {
|
|
||||||
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
|
|
||||||
|
|
||||||
impl ApFederationConfig {
|
|
||||||
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
|
||||||
let config = if debug {
|
|
||||||
FederationConfig::builder()
|
|
||||||
.domain(&data.domain)
|
|
||||||
.app_data(data)
|
|
||||||
.debug(true)
|
|
||||||
.http_signature_compat(true)
|
|
||||||
.url_verifier(Box::new(PermissiveVerifier))
|
|
||||||
.build()
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
FederationConfig::builder()
|
|
||||||
.domain(&data.domain)
|
|
||||||
.app_data(data)
|
|
||||||
.debug(false)
|
|
||||||
.build()
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(Self(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_request_data(&self) -> Data<FederationData> {
|
|
||||||
self.0.to_request_data()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
|
||||||
FederationMiddleware::new(self.0.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
|
||||||
use axum::extract::{Path, Query};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::urls::AP_PAGE_SIZE;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PageQuery {
|
|
||||||
page: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn collection_handler(
|
|
||||||
user_id_str: &str,
|
|
||||||
query: PageQuery,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
collection_type: &str,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
let user_id = uuid::Uuid::parse_str(user_id_str)
|
|
||||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
|
||||||
|
|
||||||
data.user_repo
|
|
||||||
.find_by_id(user_id)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let collection_id = format!(
|
|
||||||
"{}/users/{}/{}",
|
|
||||||
data.base_url, user_id_str, collection_type
|
|
||||||
);
|
|
||||||
|
|
||||||
let total = match collection_type {
|
|
||||||
"followers" => data.federation_repo.count_followers(user_id).await,
|
|
||||||
_ => data.federation_repo.count_following(user_id).await,
|
|
||||||
}
|
|
||||||
.map_err(Error::from)?;
|
|
||||||
|
|
||||||
if let Some(page) = query.page {
|
|
||||||
let page = page.max(1);
|
|
||||||
let offset = (page.saturating_sub(1) as usize) * AP_PAGE_SIZE;
|
|
||||||
|
|
||||||
let items: Vec<String> = match collection_type {
|
|
||||||
"followers" => data
|
|
||||||
.federation_repo
|
|
||||||
.get_followers_page(user_id, offset as u32, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|f| f.actor.url)
|
|
||||||
.collect(),
|
|
||||||
_ => data
|
|
||||||
.federation_repo
|
|
||||||
.get_following_page(user_id, offset as u32, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| a.url)
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let has_next = offset + items.len() < total;
|
|
||||||
|
|
||||||
let mut obj = json!({
|
|
||||||
"@context": crate::urls::AP_CONTEXT,
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"id": format!("{}?page={}", collection_id, page),
|
|
||||||
"partOf": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"orderedItems": items,
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_next {
|
|
||||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(FederationJson(obj))
|
|
||||||
} else {
|
|
||||||
Ok(FederationJson(json!({
|
|
||||||
"@context": crate::urls::AP_CONTEXT,
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"id": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"first": format!("{}?page=1", collection_id),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn followers_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<PageQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
collection_handler(&user_id_str, query, data, "followers").await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn following_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<PageQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
collection_handler(&user_id_str, query, data, "following").await
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
axum::inbox::{ActivityData, receive_activity},
|
|
||||||
config::Data,
|
|
||||||
protocol::context::WithContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::activities::InboxActivities;
|
|
||||||
use crate::actors::DbActor;
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub async fn inbox_handler(
|
|
||||||
data: Data<FederationData>,
|
|
||||||
activity_data: ActivityData,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
pub mod activities;
|
|
||||||
pub mod actor_handler;
|
|
||||||
pub mod actors;
|
|
||||||
pub mod ap_ports;
|
|
||||||
pub mod content;
|
|
||||||
pub mod data;
|
|
||||||
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;
|
|
||||||
pub(crate) mod urls;
|
|
||||||
pub use urls::AS_PUBLIC;
|
|
||||||
pub mod user;
|
|
||||||
pub mod webfinger;
|
|
||||||
|
|
||||||
pub use activitypub_federation::kinds::object::NoteType;
|
|
||||||
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort};
|
|
||||||
pub use content::ApObjectHandler;
|
|
||||||
pub use data::FederationData;
|
|
||||||
pub use error::Error;
|
|
||||||
pub use federation::ApFederationConfig;
|
|
||||||
pub use repository::{
|
|
||||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
|
||||||
};
|
|
||||||
pub use service::ActivityPubService;
|
|
||||||
pub use user::{ApProfileField, ApUser, ApUserRepository};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use activitypub_federation::config::Data;
|
|
||||||
use axum::Json;
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
const NODEINFO_2_0_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
|
||||||
|
|
||||||
#[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: NODEINFO_2_0_REL.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)]
|
|
||||||
#[path = "tests/nodeinfo.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
use axum::extract::{Path, Query};
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use activitypub_federation::{
|
|
||||||
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
|
||||||
protocol::context::WithContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{activities::CreateActivity, data::FederationData, error::Error, urls::AP_PAGE_SIZE};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct OutboxQuery {
|
|
||||||
page: Option<bool>,
|
|
||||||
before: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OrderedCollection {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
context: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: String,
|
|
||||||
id: String,
|
|
||||||
total_items: u64,
|
|
||||||
first: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OrderedCollectionPage {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
context: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: String,
|
|
||||||
id: String,
|
|
||||||
part_of: String,
|
|
||||||
ordered_items: Vec<serde_json::Value>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
next: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn outbox_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<OutboxQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<axum::response::Response, Error> {
|
|
||||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
|
||||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
|
||||||
|
|
||||||
data.user_repo
|
|
||||||
.find_by_id(uuid)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
|
||||||
|
|
||||||
if query.page.unwrap_or(false) {
|
|
||||||
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
|
||||||
|
|
||||||
let items = data
|
|
||||||
.object_handler
|
|
||||||
.get_local_objects_page(uuid, before, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
|
|
||||||
|
|
||||||
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
|
|
||||||
.parse()
|
|
||||||
.expect("valid url");
|
|
||||||
|
|
||||||
let has_more = items.len() == AP_PAGE_SIZE;
|
|
||||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
|
||||||
|
|
||||||
let followers_url = format!("{}/followers", actor_url);
|
|
||||||
let ordered_items: Vec<serde_json::Value> = items
|
|
||||||
.into_iter()
|
|
||||||
.map(|(ap_id, object, _)| {
|
|
||||||
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
|
||||||
serde_json::to_value(WithContext::new_default(CreateActivity {
|
|
||||||
id: create_id,
|
|
||||||
kind: CreateType::default(),
|
|
||||||
actor: ObjectId::from(actor_url.clone()),
|
|
||||||
object,
|
|
||||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![followers_url.clone()],
|
|
||||||
bto: vec![],
|
|
||||||
bcc: vec![],
|
|
||||||
}))
|
|
||||||
.expect("serializable")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let page_id = match &query.before {
|
|
||||||
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
|
|
||||||
None => format!("{}?page=true", outbox_url),
|
|
||||||
};
|
|
||||||
|
|
||||||
let next = if has_more {
|
|
||||||
oldest_ts.map(|ts| {
|
|
||||||
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
|
||||||
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
|
||||||
format!("{}?page=true&before={}", outbox_url, ts_str)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(axum::Json(OrderedCollectionPage {
|
|
||||||
context: crate::urls::AP_CONTEXT.to_string(),
|
|
||||||
kind: "OrderedCollectionPage".to_string(),
|
|
||||||
id: page_id,
|
|
||||||
part_of: outbox_url,
|
|
||||||
ordered_items,
|
|
||||||
next,
|
|
||||||
})
|
|
||||||
.into_response())
|
|
||||||
} else {
|
|
||||||
let total = data
|
|
||||||
.object_handler
|
|
||||||
.get_local_objects_for_user(uuid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
|
|
||||||
.len() as u64;
|
|
||||||
|
|
||||||
Ok(axum::Json(OrderedCollection {
|
|
||||||
context: crate::urls::AP_CONTEXT.to_string(),
|
|
||||||
kind: "OrderedCollection".to_string(),
|
|
||||||
id: outbox_url.clone(),
|
|
||||||
total_items: total,
|
|
||||||
first: format!("{}?page=true", outbox_url),
|
|
||||||
})
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum FollowerStatus {
|
|
||||||
Pending,
|
|
||||||
Accepted,
|
|
||||||
Rejected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum FollowingStatus {
|
|
||||||
Pending,
|
|
||||||
Accepted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct RemoteActor {
|
|
||||||
pub url: String,
|
|
||||||
pub handle: String,
|
|
||||||
pub inbox_url: String,
|
|
||||||
pub shared_inbox_url: Option<String>,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
pub outbox_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Follower {
|
|
||||||
pub actor: RemoteActor,
|
|
||||||
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(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowerStatus,
|
|
||||||
follow_activity_id: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_follower_follow_activity_id(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
async fn remove_follower(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
|
||||||
async fn get_followers_page(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
offset: u32,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<Follower>>;
|
|
||||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
|
||||||
async fn get_following_page(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
offset: u32,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<RemoteActor>>;
|
|
||||||
async fn update_follower_status(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowerStatus,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn add_following(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
actor: RemoteActor,
|
|
||||||
follow_activity_id: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_follow_activity_id(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
|
||||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
|
||||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
|
||||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
|
||||||
async fn get_local_actor_keypair(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> Result<Option<(String, String)>>;
|
|
||||||
async fn save_local_actor_keypair(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
public_key: String,
|
|
||||||
private_key: String,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
|
||||||
async fn update_following_status(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowingStatus,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_following_outbox_url(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
async fn add_announce(
|
|
||||||
&self,
|
|
||||||
activity_id: &str,
|
|
||||||
object_url: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn person_serializes_with_enriched_fields() {
|
|
||||||
let person = Person {
|
|
||||||
kind: Default::default(),
|
|
||||||
id: "https://example.com/users/1"
|
|
||||||
.parse::<url::Url>()
|
|
||||||
.unwrap()
|
|
||||||
.into(),
|
|
||||||
preferred_username: "alice".to_string(),
|
|
||||||
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
|
||||||
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
|
||||||
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
|
||||||
following: "https://example.com/users/1/following".parse().unwrap(),
|
|
||||||
public_key: PublicKey {
|
|
||||||
id: "https://example.com/users/1#main-key".to_string(),
|
|
||||||
owner: "https://example.com/users/1".parse().unwrap(),
|
|
||||||
public_key_pem: "pem".to_string(),
|
|
||||||
},
|
|
||||||
name: Some("Alice".to_string()),
|
|
||||||
summary: Some("Bio text".to_string()),
|
|
||||||
icon: Some(ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
|
||||||
}),
|
|
||||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
|
||||||
discoverable: Some(true),
|
|
||||||
manually_approves_followers: true,
|
|
||||||
updated: Some(Utc::now()),
|
|
||||||
endpoints: Some(Endpoints {
|
|
||||||
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
|
||||||
}),
|
|
||||||
image: None,
|
|
||||||
also_known_as: vec![],
|
|
||||||
attachment: vec![],
|
|
||||||
};
|
|
||||||
let json = serde_json::to_value(&person).unwrap();
|
|
||||||
assert_eq!(json["discoverable"], true);
|
|
||||||
assert_eq!(json["summary"], "Bio text");
|
|
||||||
assert_eq!(json["icon"]["type"], "Image");
|
|
||||||
assert_eq!(json["manuallyApprovesFollowers"], true);
|
|
||||||
assert!(json.get("updated").is_some());
|
|
||||||
assert!(json.get("endpoints").is_some());
|
|
||||||
assert_eq!(
|
|
||||||
json["endpoints"]["sharedInbox"],
|
|
||||||
"https://example.com/inbox"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
fn _assert_impl_federation_lookup_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationLookupPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _assert_impl_federation_follow_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationFollowPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _assert_impl_federation_follow_request_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationFollowRequestPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _assert_impl_federation_fetch_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationFetchPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _assert_impl_federation_action_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
|
||||||
|
|
||||||
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
|
|
||||||
Follower {
|
|
||||||
actor: RemoteActor {
|
|
||||||
url: format!("https://remote/{}", inbox),
|
|
||||||
handle: "user".to_string(),
|
|
||||||
inbox_url: inbox.to_string(),
|
|
||||||
shared_inbox_url: shared.map(|s| s.to_string()),
|
|
||||||
display_name: None,
|
|
||||||
avatar_url: None,
|
|
||||||
outbox_url: None,
|
|
||||||
},
|
|
||||||
status: FollowerStatus::Accepted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collect_inboxes_deduplicates_shared() {
|
|
||||||
let followers = vec![
|
|
||||||
make_follower(
|
|
||||||
"https://mastodon.social/users/a/inbox",
|
|
||||||
Some("https://mastodon.social/inbox"),
|
|
||||||
),
|
|
||||||
make_follower(
|
|
||||||
"https://mastodon.social/users/b/inbox",
|
|
||||||
Some("https://mastodon.social/inbox"),
|
|
||||||
),
|
|
||||||
make_follower("https://other.instance/users/c/inbox", None),
|
|
||||||
];
|
|
||||||
let inboxes = collect_inboxes(&followers);
|
|
||||||
assert_eq!(inboxes.len(), 2);
|
|
||||||
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
|
|
||||||
assert!(strs.contains(&"https://mastodon.social/inbox"));
|
|
||||||
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collect_inboxes_falls_back_to_individual_inbox() {
|
|
||||||
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
|
|
||||||
let inboxes = collect_inboxes(&followers);
|
|
||||||
assert_eq!(inboxes.len(), 1);
|
|
||||||
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
|
|
||||||
pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
|
|
||||||
pub const AP_PAGE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
|
||||||
let path = url.path();
|
|
||||||
path.strip_prefix("/users/")
|
|
||||||
.and_then(|s| s.split('/').next())
|
|
||||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
|
|
||||||
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
|
||||||
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
|
||||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
|
||||||
.expect("base_url is always a valid URL prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the username segment from a /users/:username URL.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn extract_username_from_url(url: &Url) -> Option<String> {
|
|
||||||
url.path()
|
|
||||||
.strip_prefix("/users/")
|
|
||||||
.and_then(|s| s.split('/').next())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ApProfileField {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ApUser {
|
|
||||||
pub id: uuid::Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub bio: Option<String>,
|
|
||||||
pub avatar_url: Option<Url>,
|
|
||||||
pub banner_url: Option<Url>,
|
|
||||||
pub also_known_as: Option<String>,
|
|
||||||
pub profile_url: Option<Url>,
|
|
||||||
pub attachment: Vec<ApProfileField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
config::Data,
|
|
||||||
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::Query,
|
|
||||||
http::header,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WebfingerQuery {
|
|
||||||
resource: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn webfinger_handler(
|
|
||||||
Query(query): Query<WebfingerQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<Response, Error> {
|
|
||||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
|
||||||
|
|
||||||
let user = data
|
|
||||||
.user_repo
|
|
||||||
.find_by_username(name)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
|
||||||
|
|
||||||
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
|
|
||||||
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -14,3 +14,8 @@ chrono = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
activitypub_federation = "0.7.0-beta.11"
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use chrono::{DateTime, Utc};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::note::ThoughtNote;
|
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||||
|
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||||
use crate::urls::ThoughtsUrls;
|
use crate::urls::ThoughtsUrls;
|
||||||
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
|
use k_ap::ApObjectHandler;
|
||||||
use domain::ports::{EventPublisher, TagRepository};
|
use domain::ports::{EventPublisher, TagRepository};
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
@@ -58,16 +59,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.thought
|
.thought
|
||||||
.in_reply_to_id
|
.in_reply_to_id
|
||||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
let note = ThoughtNote::new_public(
|
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||||
note_url.clone(),
|
id: note_url.clone(),
|
||||||
actor_url,
|
actor_url,
|
||||||
e.thought.content.as_str().to_owned(),
|
content: e.thought.content.as_str().to_owned(),
|
||||||
e.thought.created_at,
|
published: e.thought.created_at,
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
e.thought.sensitive,
|
sensitive: e.thought.sensitive,
|
||||||
e.thought.content_warning,
|
summary: e.thought.content_warning,
|
||||||
followers,
|
followers_url: followers,
|
||||||
);
|
});
|
||||||
Ok((note_url, serde_json::to_value(¬e)?))
|
Ok((note_url, serde_json::to_value(¬e)?))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -96,16 +97,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.thought
|
.thought
|
||||||
.in_reply_to_id
|
.in_reply_to_id
|
||||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
let note = ThoughtNote::new_public(
|
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||||
note_url.clone(),
|
id: note_url.clone(),
|
||||||
actor_url,
|
actor_url,
|
||||||
e.thought.content.as_str().to_owned(),
|
content: e.thought.content.as_str().to_owned(),
|
||||||
created_at,
|
published: created_at,
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
e.thought.sensitive,
|
sensitive: e.thought.sensitive,
|
||||||
e.thought.content_warning,
|
summary: e.thought.content_warning,
|
||||||
followers,
|
followers_url: followers,
|
||||||
);
|
});
|
||||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -141,17 +142,18 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
"direct"
|
"direct"
|
||||||
};
|
};
|
||||||
|
|
||||||
let thought_id = self.repo
|
let thought_id = self
|
||||||
.accept_note(
|
.repo
|
||||||
ap_id.as_str(),
|
.accept_note(AcceptNoteInput {
|
||||||
&author_id,
|
ap_id: ap_id.as_str(),
|
||||||
¬e.content,
|
author_id: &author_id,
|
||||||
note.published,
|
content: ¬e.content,
|
||||||
note.sensitive,
|
published: note.published,
|
||||||
note.summary,
|
sensitive: note.sensitive,
|
||||||
|
content_warning: note.summary,
|
||||||
visibility,
|
visibility,
|
||||||
note.in_reply_to.as_ref().map(|u| u.as_str()),
|
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod note;
|
pub mod note;
|
||||||
|
pub mod port;
|
||||||
|
pub mod service;
|
||||||
pub mod urls;
|
pub mod urls;
|
||||||
|
|
||||||
pub use handler::ThoughtsObjectHandler;
|
pub use handler::ThoughtsObjectHandler;
|
||||||
pub use note::ThoughtNote;
|
pub use note::ThoughtNote;
|
||||||
|
pub use port::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
|
||||||
|
pub use service::ApFederationAdapter;
|
||||||
pub use urls::ThoughtsUrls;
|
pub use urls::ThoughtsUrls;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use activitypub_base::NoteType;
|
use k_ap::NoteType;
|
||||||
use activitypub_base::AS_PUBLIC;
|
use k_ap::AS_PUBLIC;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -11,7 +11,8 @@ pub struct ThoughtNote {
|
|||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub kind: NoteType,
|
pub kind: NoteType,
|
||||||
pub id: Url,
|
pub id: Url,
|
||||||
pub url: Url, // Mastodon uses this as the clickable link
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub url: Option<Url>,
|
||||||
pub attributed_to: Url,
|
pub attributed_to: Url,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub published: DateTime<Utc>,
|
pub published: DateTime<Utc>,
|
||||||
@@ -21,6 +22,7 @@ pub struct ThoughtNote {
|
|||||||
pub cc: Vec<String>,
|
pub cc: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub in_reply_to: Option<Url>,
|
pub in_reply_to: Option<Url>,
|
||||||
|
#[serde(default)]
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
@@ -28,30 +30,31 @@ pub struct ThoughtNote {
|
|||||||
pub tag: Vec<serde_json::Value>,
|
pub tag: Vec<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ThoughtNoteInput {
|
||||||
|
pub id: Url,
|
||||||
|
pub actor_url: Url,
|
||||||
|
pub content: String,
|
||||||
|
pub published: DateTime<Utc>,
|
||||||
|
pub in_reply_to: Option<Url>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub followers_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
impl ThoughtNote {
|
impl ThoughtNote {
|
||||||
#[allow(clippy::too_many_arguments)]
|
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
||||||
pub fn new_public(
|
|
||||||
id: Url,
|
|
||||||
actor_url: Url,
|
|
||||||
content: String,
|
|
||||||
published: DateTime<Utc>,
|
|
||||||
in_reply_to: Option<Url>,
|
|
||||||
sensitive: bool,
|
|
||||||
summary: Option<String>,
|
|
||||||
followers_url: Url,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
url: id.clone(),
|
url: Some(p.id.clone()),
|
||||||
id,
|
id: p.id,
|
||||||
attributed_to: actor_url,
|
attributed_to: p.actor_url,
|
||||||
content,
|
content: p.content,
|
||||||
published,
|
published: p.published,
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
to: vec![AS_PUBLIC.to_string()],
|
||||||
cc: vec![followers_url.to_string()],
|
cc: vec![p.followers_url.to_string()],
|
||||||
in_reply_to,
|
in_reply_to: p.in_reply_to,
|
||||||
sensitive,
|
sensitive: p.sensitive,
|
||||||
summary,
|
summary: p.summary,
|
||||||
tag: Vec::new(),
|
tag: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ use super::*;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn note_serializes_with_public_audience() {
|
fn note_serializes_with_public_audience() {
|
||||||
let note = ThoughtNote::new_public(
|
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
|
||||||
"https://example.com/thoughts/1".parse().unwrap(),
|
id: "https://example.com/thoughts/1".parse().unwrap(),
|
||||||
"https://example.com/users/alice".parse().unwrap(),
|
actor_url: "https://example.com/users/alice".parse().unwrap(),
|
||||||
"Hello world".to_string(),
|
content: "Hello world".to_string(),
|
||||||
chrono::Utc::now(),
|
published: chrono::Utc::now(),
|
||||||
None,
|
in_reply_to: None,
|
||||||
false,
|
sensitive: false,
|
||||||
None,
|
summary: None,
|
||||||
"https://example.com/users/alice/followers".parse().unwrap(),
|
followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
|
||||||
);
|
});
|
||||||
let json = serde_json::to_string(¬e).unwrap();
|
let json = serde_json::to_string(¬e).unwrap();
|
||||||
assert!(json.contains(AS_PUBLIC));
|
assert!(json.contains(AS_PUBLIC));
|
||||||
assert!(json.contains("Hello world"));
|
assert!(json.contains("Hello world"));
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ use domain::{
|
|||||||
value_objects::{ThoughtId, UserId, Username},
|
value_objects::{ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct AcceptNoteInput<'a> {
|
||||||
|
pub ap_id: &'a str,
|
||||||
|
pub author_id: &'a UserId,
|
||||||
|
pub content: &'a str,
|
||||||
|
pub published: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub visibility: &'a str,
|
||||||
|
pub in_reply_to: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ActorApUrls {
|
pub struct ActorApUrls {
|
||||||
@@ -61,18 +72,8 @@ pub trait ActivityPubRepository: Send + Sync {
|
|||||||
// ── Inbox processing (remote → local) ───────────────────────────
|
// ── Inbox processing (remote → local) ───────────────────────────
|
||||||
|
|
||||||
/// Persist an incoming remote Note. Idempotent on ap_id.
|
/// Persist an incoming remote Note. Idempotent on ap_id.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn accept_note(
|
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
|
||||||
&self,
|
|
||||||
ap_id: &str,
|
|
||||||
author_id: &UserId,
|
|
||||||
content: &str,
|
|
||||||
published: chrono::DateTime<chrono::Utc>,
|
|
||||||
sensitive: bool,
|
|
||||||
content_warning: Option<String>,
|
|
||||||
visibility: &str,
|
|
||||||
in_reply_to: Option<&str>,
|
|
||||||
) -> Result<ThoughtId, DomainError>;
|
|
||||||
|
|
||||||
/// Apply an Update to a previously accepted remote Note.
|
/// Apply an Update to a previously accepted remote Note.
|
||||||
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
|
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
|
||||||
830
crates/adapters/activitypub/src/service.rs
Normal file
830
crates/adapters/activitypub/src/service.rs
Normal file
@@ -0,0 +1,830 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_ap::ActivityPubService;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::remote_actor::RemoteActor as DomainRemoteActor,
|
||||||
|
ports::{
|
||||||
|
FederationFetchPort, FederationFollowPort, FederationFollowRequestPort,
|
||||||
|
FederationLookupPort, FederationSchedulerPort, RemoteActorConnectionRepository,
|
||||||
|
},
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
||||||
|
const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn content_to_html(text: &str) -> String {
|
||||||
|
let escaped = text
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """);
|
||||||
|
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
||||||
|
if paragraphs.is_empty() {
|
||||||
|
format!("<p>{}</p>", escaped)
|
||||||
|
} else {
|
||||||
|
paragraphs
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("<p>{}</p>", p))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_note_json(
|
||||||
|
thought: &domain::models::thought::Thought,
|
||||||
|
local_actor_ap_id: &str,
|
||||||
|
local_actor_followers_url: &str,
|
||||||
|
base_url: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let ap_id = format!("{}/thoughts/{}", base_url, thought.id);
|
||||||
|
|
||||||
|
let (to, cc) = match thought.visibility {
|
||||||
|
domain::models::thought::Visibility::Public => (
|
||||||
|
vec![k_ap::AS_PUBLIC.to_string()],
|
||||||
|
vec![local_actor_followers_url.to_string()],
|
||||||
|
),
|
||||||
|
domain::models::thought::Visibility::Unlisted => (
|
||||||
|
vec![local_actor_followers_url.to_string()],
|
||||||
|
vec![k_ap::AS_PUBLIC.to_string()],
|
||||||
|
),
|
||||||
|
domain::models::thought::Visibility::Followers => {
|
||||||
|
(vec![local_actor_followers_url.to_string()], vec![])
|
||||||
|
}
|
||||||
|
domain::models::thought::Visibility::Direct => (vec![], vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut note = serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"id": ap_id,
|
||||||
|
"url": ap_id,
|
||||||
|
"attributedTo": local_actor_ap_id,
|
||||||
|
"content": content_to_html(thought.content.as_str()),
|
||||||
|
"published": thought.created_at.to_rfc3339(),
|
||||||
|
"to": to,
|
||||||
|
"cc": cc,
|
||||||
|
"sensitive": thought.sensitive,
|
||||||
|
});
|
||||||
|
if let Some(ref cw) = thought.content_warning {
|
||||||
|
note["summary"] = serde_json::json!(cw);
|
||||||
|
}
|
||||||
|
if let Some(reply_url) = in_reply_to_url {
|
||||||
|
note["inReplyTo"] = serde_json::json!(reply_url);
|
||||||
|
}
|
||||||
|
if let Some(updated_at) = thought.updated_at {
|
||||||
|
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
|
||||||
|
}
|
||||||
|
let hashtags = domain::hashtag::extract(thought.content.as_str());
|
||||||
|
if !hashtags.is_empty() {
|
||||||
|
let ap_tags: Vec<serde_json::Value> = hashtags
|
||||||
|
.iter()
|
||||||
|
.map(|h| {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "Hashtag",
|
||||||
|
"name": h.ap_name,
|
||||||
|
"href": format!("{}/{}", base_url, h.url_slug),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
note["tag"] = serde_json::json!(ap_tags);
|
||||||
|
}
|
||||||
|
note
|
||||||
|
}
|
||||||
|
|
||||||
|
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||||
|
DomainRemoteActor {
|
||||||
|
url: a.url,
|
||||||
|
handle: a.handle,
|
||||||
|
display_name: a.display_name,
|
||||||
|
avatar_url: a.avatar_url,
|
||||||
|
outbox_url: a.outbox_url,
|
||||||
|
last_fetched_at: chrono::Utc::now(),
|
||||||
|
bio: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
followers_url: None,
|
||||||
|
following_url: None,
|
||||||
|
attachment: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_actor_profiles_from_urls(
|
||||||
|
urls: Vec<String>,
|
||||||
|
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||||
|
use futures::future;
|
||||||
|
|
||||||
|
async fn fetch_one(
|
||||||
|
url: String,
|
||||||
|
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||||
|
let resp: serde_json::Value = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
reqwest::Client::new()
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/activity+json")
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.ok()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let ap_url = resp["id"].as_str()?.to_string();
|
||||||
|
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
|
||||||
|
let domain_str = url::Url::parse(&ap_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let handle = format!("{}@{}", preferred_username, domain_str);
|
||||||
|
let display_name = resp["name"].as_str().map(|s| s.to_string());
|
||||||
|
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
Some(domain::models::actor_connection_summary::ActorConnectionSummary {
|
||||||
|
url: ap_url,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
|
||||||
|
let results = future::join_all(futs).await;
|
||||||
|
|
||||||
|
results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
if r.is_none() {
|
||||||
|
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
|
||||||
|
}
|
||||||
|
r
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
|
||||||
|
let normalized = handle.trim_start_matches('@');
|
||||||
|
let at = normalized
|
||||||
|
.rfind('@')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
|
||||||
|
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
||||||
|
let wf_url = format!(
|
||||||
|
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
|
||||||
|
domain_str, user, domain_str
|
||||||
|
);
|
||||||
|
let wf: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&wf_url)
|
||||||
|
.header("Accept", "application/jrd+json, application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
let self_href = wf["links"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|links| {
|
||||||
|
links.iter().find(|l| {
|
||||||
|
l["rel"].as_str() == Some("self")
|
||||||
|
&& l["type"].as_str() == Some("application/activity+json")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|l| l["href"].as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
|
||||||
|
.to_owned();
|
||||||
|
Ok(self_href)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ApFederationAdapter ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps `k_ap::ActivityPubService` together with the `RemoteActorConnectionRepository`
|
||||||
|
/// (which k-ap doesn't own), and implements all domain federation port traits.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApFederationAdapter {
|
||||||
|
pub(crate) inner: Arc<ActivityPubService>,
|
||||||
|
pub(crate) connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApFederationAdapter {
|
||||||
|
pub fn new(
|
||||||
|
inner: Arc<ActivityPubService>,
|
||||||
|
connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
connections_repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router<S>(&self) -> axum::Router<S>
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
self.inner.router()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_url(&self) -> &str {
|
||||||
|
self.inner.base_url()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_ap_id(&self, user_uuid: uuid::Uuid) -> String {
|
||||||
|
format!("{}/users/{}", self.base_url(), user_uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_followers_url(&self, user_uuid: uuid::Uuid) -> String {
|
||||||
|
format!("{}/followers", self.actor_ap_id(user_uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OutboundFederationPort ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
||||||
|
async fn broadcast_create(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought: &domain::models::thought::Thought,
|
||||||
|
_author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let user_uuid = author_user_id.as_uuid();
|
||||||
|
let ap_id = self.actor_ap_id(user_uuid);
|
||||||
|
let followers_url = self.actor_followers_url(user_uuid);
|
||||||
|
let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url);
|
||||||
|
self.inner
|
||||||
|
.broadcast_create_note(user_uuid, note)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_delete(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let ap_id = url::Url::parse(thought_ap_id)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_update(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought: &domain::models::thought::Thought,
|
||||||
|
_author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let user_uuid = author_user_id.as_uuid();
|
||||||
|
let ap_id = self.actor_ap_id(user_uuid);
|
||||||
|
let followers_url = self.actor_followers_url(user_uuid);
|
||||||
|
let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url);
|
||||||
|
self.inner
|
||||||
|
.broadcast_update_note(user_uuid, note)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_announce(
|
||||||
|
&self,
|
||||||
|
booster_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let ap_id = url::Url::parse(object_ap_id)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_undo_announce(
|
||||||
|
&self,
|
||||||
|
booster_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let ap_id = url::Url::parse(object_ap_id)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_like(
|
||||||
|
&self,
|
||||||
|
liker_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
author_inbox_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let object = url::Url::parse(object_ap_id)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
let inbox = url::Url::parse(author_inbox_url)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_undo_like(
|
||||||
|
&self,
|
||||||
|
liker_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
author_inbox_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let object = url::Url::parse(object_ap_id)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
let inbox = url::Url::parse(author_inbox_url)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
self.inner
|
||||||
|
.broadcast_actor_update(user_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FederationSchedulerPort ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationSchedulerPort for ApFederationAdapter {
|
||||||
|
async fn schedule_actor_posts_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
outbox_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let service = self.inner.clone();
|
||||||
|
let actor = actor_ap_url.to_string();
|
||||||
|
let outbox = outbox_url.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = service.backfill_outbox(&outbox, &actor).await {
|
||||||
|
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn schedule_connections_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
collection_url: &str,
|
||||||
|
connection_type: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if page != 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let actor = actor_ap_url.to_string();
|
||||||
|
let collection = collection_url.to_string();
|
||||||
|
let conn_type = connection_type.to_string();
|
||||||
|
let connections_repo = self.connections_repo.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "connections fetch: failed to build client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_urls: Vec<String> = Vec::new();
|
||||||
|
let mut current_url: Option<String> = Some(collection.clone());
|
||||||
|
const MAX_ACTORS: usize = 500;
|
||||||
|
|
||||||
|
while let Some(url) = current_url.take() {
|
||||||
|
let val: serde_json::Value = match client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => match r.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, url = %url, "connections: parse error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, url = %url, "connections: HTTP error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if val["type"].as_str() == Some("OrderedCollection") {
|
||||||
|
current_url = val["first"].as_str().map(|s| s.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let empty = vec![];
|
||||||
|
let items = val["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
|
for item in items {
|
||||||
|
let actor_url =
|
||||||
|
item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
|
||||||
|
if !actor_url.is_empty() {
|
||||||
|
all_urls.push(actor_url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_urls.len() >= MAX_ACTORS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current_url = val["next"].as_str().map(|s| s.to_string());
|
||||||
|
if current_url.is_some() {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_urls.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
actor = %actor,
|
||||||
|
connection_type = %conn_type,
|
||||||
|
"connections: empty collection"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 20;
|
||||||
|
for (idx, chunk) in all_urls.chunks(PAGE_SIZE).enumerate() {
|
||||||
|
let page_num = (idx + 1) as u32;
|
||||||
|
let resolved = resolve_actor_profiles_from_urls(chunk.to_vec()).await;
|
||||||
|
if let Err(e) = connections_repo
|
||||||
|
.upsert_connections(&actor, &conn_type, page_num, &resolved)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "connections: upsert failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
actor = %actor,
|
||||||
|
connection_type = %conn_type,
|
||||||
|
count = all_urls.len(),
|
||||||
|
"connections fetch complete"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FederationLookupPort ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationLookupPort for ApFederationAdapter {
|
||||||
|
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
||||||
|
let normalized = handle.trim_start_matches('@');
|
||||||
|
let at = normalized.rfind('@').ok_or_else(|| {
|
||||||
|
DomainError::InvalidInput("handle must be user@domain".into())
|
||||||
|
})?;
|
||||||
|
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
||||||
|
|
||||||
|
let wf_url = format!(
|
||||||
|
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
|
||||||
|
domain_str, user, domain_str
|
||||||
|
);
|
||||||
|
let wf: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&wf_url)
|
||||||
|
.header("Accept", "application/jrd+json, application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let self_href = wf["links"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|links| {
|
||||||
|
links.iter().find(|l| {
|
||||||
|
l["rel"].as_str() == Some("self")
|
||||||
|
&& l["type"].as_str() == Some("application/activity+json")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|l| l["href"].as_str())
|
||||||
|
.ok_or(DomainError::NotFound)?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let actor_json: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&self_href)
|
||||||
|
.header("Accept", "application/activity+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
|
||||||
|
let preferred_username =
|
||||||
|
actor_json["preferredUsername"].as_str().unwrap_or("").to_string();
|
||||||
|
let domain_part = url::Url::parse(&ap_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let full_handle = format!("{}@{}", preferred_username, domain_part);
|
||||||
|
|
||||||
|
Ok(DomainRemoteActor {
|
||||||
|
url: ap_url.clone(),
|
||||||
|
handle: full_handle,
|
||||||
|
display_name: actor_json["name"].as_str().map(|s| s.to_string()),
|
||||||
|
avatar_url: actor_json["icon"]["url"].as_str().map(|s| s.to_string()),
|
||||||
|
outbox_url: actor_json["outbox"].as_str().map(|s| s.to_string()),
|
||||||
|
last_fetched_at: chrono::Utc::now(),
|
||||||
|
bio: actor_json["summary"].as_str().map(|s| s.to_string()),
|
||||||
|
banner_url: actor_json["image"]["url"].as_str().map(|s| s.to_string()),
|
||||||
|
also_known_as: None,
|
||||||
|
followers_url: actor_json["followers"].as_str().map(|s| s.to_string()),
|
||||||
|
following_url: actor_json["following"].as_str().map(|s| s.to_string()),
|
||||||
|
attachment: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.actor_json(&user_id.as_uuid().to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn followers_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.followers_collection_json(user_id.as_uuid(), page)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.following_collection_json(user_id.as_uuid(), page)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FederationFetchPort ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationFetchPort for ApFederationAdapter {
|
||||||
|
async fn fetch_outbox_page(
|
||||||
|
&self,
|
||||||
|
outbox_url: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Vec<domain::models::remote_note::RemoteNote>, DomainError> {
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let base: serde_json::Value = client
|
||||||
|
.get(outbox_url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let url = base["first"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{}?page={}", outbox_url, page));
|
||||||
|
|
||||||
|
let resp: serde_json::Value = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let empty = vec![];
|
||||||
|
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
|
|
||||||
|
let notes = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let note = if item["type"].as_str() == Some("Create") {
|
||||||
|
&item["object"]
|
||||||
|
} else if item["type"].as_str() == Some("Note") {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let to = note["to"].as_array()?;
|
||||||
|
let is_public = to
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
|
||||||
|
if !is_public {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let published =
|
||||||
|
DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
||||||
|
.ok()?
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
|
||||||
|
let text = note["content"].as_str().unwrap_or("").to_string();
|
||||||
|
let has_attachments = note["attachment"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| !a.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let content = if has_attachments {
|
||||||
|
let notice =
|
||||||
|
"<p class=\"media-notice\">📎 Media attachment — not supported</p>";
|
||||||
|
if text.is_empty() {
|
||||||
|
notice.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{text}{notice}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(domain::models::remote_note::RemoteNote {
|
||||||
|
ap_id: note["id"].as_str()?.to_string(),
|
||||||
|
content,
|
||||||
|
published,
|
||||||
|
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||||
|
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_actor_urls_from_collection(
|
||||||
|
&self,
|
||||||
|
collection_url: &str,
|
||||||
|
) -> Result<Vec<String>, DomainError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let base: serde_json::Value = client
|
||||||
|
.get(collection_url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let page = if base["orderedItems"].is_null() {
|
||||||
|
if let Some(first_url) = base["first"].as_str() {
|
||||||
|
client
|
||||||
|
.get(first_url)
|
||||||
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
};
|
||||||
|
|
||||||
|
let empty = vec![];
|
||||||
|
let items = page["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
|
Ok(items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_actor_profiles(
|
||||||
|
&self,
|
||||||
|
urls: Vec<String>,
|
||||||
|
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||||
|
resolve_actor_profiles_from_urls(urls).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FederationFollowPort ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationFollowPort for ApFederationAdapter {
|
||||||
|
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
|
||||||
|
self.inner
|
||||||
|
.follow(local_user_id.as_uuid(), handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfollow_remote(
|
||||||
|
&self,
|
||||||
|
local_user_id: &UserId,
|
||||||
|
handle: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let actor_url = webfinger_resolve_actor_url(handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
self.inner
|
||||||
|
.unfollow(local_user_id.as_uuid(), &actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_remote_following(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.get_following(user_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FederationFollowRequestPort ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationFollowRequestPort for ApFederationAdapter {
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.get_pending_followers(user_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn accept_follow_request(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.inner
|
||||||
|
.accept_follower(user_id.as_uuid(), actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_follow_request(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.inner
|
||||||
|
.reject_follower(user_id.as_uuid(), actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_remote_followers(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||||
|
self.inner
|
||||||
|
.get_accepted_followers(user_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_remote_follower(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.inner
|
||||||
|
.remove_follower(user_id.as_uuid(), actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FederationActionPort is a blanket supertrait; no explicit impl needed.
|
||||||
@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||||
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.key_hash == hash)
|
||||||
|
.cloned())
|
||||||
}
|
}
|
||||||
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
|
|||||||
@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use activitypub_base::{
|
use k_ap::{
|
||||||
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
|
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
|
||||||
FollowingStatus, RemoteActor,
|
FollowingStatus, RemoteActor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{SearchPort, ThoughtRepository, UserWriter},
|
ports::{SearchPort, ThoughtRepository, UserWriter},
|
||||||
@@ -19,15 +19,15 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
|
|||||||
PasswordHash("h".into()),
|
PasswordHash("h".into()),
|
||||||
);
|
);
|
||||||
urepo.save(&u).await.unwrap();
|
urepo.save(&u).await.unwrap();
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
u.id.clone(),
|
user_id: u.id.clone(),
|
||||||
Content::new_local(content).unwrap(),
|
content: Content::new_local(content).unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(u, t)
|
(u, t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
activitypub-base = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
event-payload = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
use activitypub::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
@@ -210,17 +210,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn accept_note(
|
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
|
||||||
&self,
|
let AcceptNoteInput {
|
||||||
ap_id: &str,
|
ap_id,
|
||||||
author_id: &UserId,
|
author_id,
|
||||||
content: &str,
|
content,
|
||||||
published: DateTime<Utc>,
|
published,
|
||||||
sensitive: bool,
|
sensitive,
|
||||||
content_warning: Option<String>,
|
content_warning,
|
||||||
visibility: &str,
|
visibility,
|
||||||
in_reply_to: Option<&str>,
|
in_reply_to,
|
||||||
) -> Result<ThoughtId, DomainError> {
|
} = input;
|
||||||
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||||
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
||||||
Some(url) => {
|
Some(url) => {
|
||||||
@@ -254,8 +254,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
|
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
|
||||||
let row: (uuid::Uuid,) =
|
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||||
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
|
||||||
.bind(ap_id)
|
.bind(ap_id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use activitypub_base::ActivityPubRepository;
|
use activitypub::{AcceptNoteInput, ActivityPubRepository};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool);
|
let repo = PgActivityPubRepository::new(pool);
|
||||||
let url = "https://mastodon.social/users/alice";
|
let url = "https://mastodon.social/users/alice";
|
||||||
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||||
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||||
assert_eq!(id1, id2);
|
assert_eq!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool);
|
let repo = PgActivityPubRepository::new(pool);
|
||||||
let actor_url = "https://remote.example/users/bob";
|
let actor_url = "https://remote.example/users/bob";
|
||||||
let ap_id = "https://remote.example/notes/1";
|
let ap_id = "https://remote.example/notes/1";
|
||||||
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||||
repo.accept_note(
|
repo.accept_note(AcceptNoteInput {
|
||||||
ap_id,
|
ap_id,
|
||||||
&author,
|
author_id: &author,
|
||||||
"hello from remote",
|
content: "hello from remote",
|
||||||
chrono::Utc::now(),
|
published: chrono::Utc::now(),
|
||||||
false,
|
sensitive: false,
|
||||||
None,
|
content_warning: None,
|
||||||
"public",
|
visibility: "public",
|
||||||
None,
|
in_reply_to: None,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.retract_note(ap_id).await.unwrap();
|
repo.retract_note(ap_id).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool);
|
let repo = PgActivityPubRepository::new(pool);
|
||||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool.clone());
|
let repo = PgActivityPubRepository::new(pool.clone());
|
||||||
let actor_user_id = repo
|
let actor_user_id = repo
|
||||||
.intern_remote_actor("https://remote.example/users/alice")
|
.intern_remote_actor("https://remote.example/users/alice")
|
||||||
@@ -46,16 +46,16 @@
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let thought_id = repo
|
let thought_id = repo
|
||||||
.accept_note(
|
.accept_note(AcceptNoteInput {
|
||||||
"https://remote.example/notes/1",
|
ap_id: "https://remote.example/notes/1",
|
||||||
&actor_user_id,
|
author_id: &actor_user_id,
|
||||||
"Hello #rust world",
|
content: "Hello #rust world",
|
||||||
chrono::Utc::now(),
|
published: chrono::Utc::now(),
|
||||||
false,
|
sensitive: false,
|
||||||
None,
|
content_warning: None,
|
||||||
"public",
|
visibility: "public",
|
||||||
None,
|
in_reply_to: None,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -65,4 +65,4 @@
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(thought_id.as_uuid(), row.0);
|
assert_eq!(thought_id.as_uuid(), row.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::user::PgUserRepository;
|
use crate::user::PgUserRepository;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::ports::UserWriter;
|
use domain::ports::UserWriter;
|
||||||
use domain::{models::user::User, value_objects::*};
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||||
let repo = PgUserRepository::new(pool.clone());
|
let repo = PgUserRepository::new(pool.clone());
|
||||||
let u = User::new_local(
|
let u = User::new_local(
|
||||||
UserId::new(),
|
UserId::new(),
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
);
|
);
|
||||||
repo.save(&u).await.unwrap();
|
repo.save(&u).await.unwrap();
|
||||||
u
|
u
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool).await;
|
let user = seed_user(&pool).await;
|
||||||
let repo = PgApiKeyRepository::new(pool);
|
let repo = PgApiKeyRepository::new(pool);
|
||||||
let key = ApiKey {
|
let key = ApiKey {
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
repo.save(&key).await.unwrap();
|
repo.save(&key).await.unwrap();
|
||||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||||
assert_eq!(found.name, "test");
|
assert_eq!(found.name, "test");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn delete_key(pool: sqlx::PgPool) {
|
async fn delete_key(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool).await;
|
let user = seed_user(&pool).await;
|
||||||
let repo = PgApiKeyRepository::new(pool);
|
let repo = PgApiKeyRepository::new(pool);
|
||||||
let key = ApiKey {
|
let key = ApiKey {
|
||||||
@@ -46,4 +46,4 @@
|
|||||||
repo.save(&key).await.unwrap();
|
repo.save(&key).await.unwrap();
|
||||||
repo.delete(&key.id, &user.id).await.unwrap();
|
repo.delete(&key.id, &user.id).await.unwrap();
|
||||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user;
|
use crate::test_helpers::seed_user;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::value_objects::*;
|
use domain::value_objects::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn block_exists(pool: sqlx::PgPool) {
|
async fn block_exists(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgBlockRepository::new(pool);
|
let repo = PgBlockRepository::new(pool);
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
repo.save(&block).await.unwrap();
|
repo.save(&block).await.unwrap();
|
||||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn unblock(pool: sqlx::PgPool) {
|
async fn unblock(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgBlockRepository::new(pool);
|
let repo = PgBlockRepository::new(pool);
|
||||||
@@ -31,4 +31,4 @@
|
|||||||
repo.save(&block).await.unwrap();
|
repo.save(&block).await.unwrap();
|
||||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user_and_thought;
|
use crate::test_helpers::seed_user_and_thought;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::value_objects::*;
|
use domain::value_objects::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||||
let repo = PgBoostRepository::new(pool);
|
let repo = PgBoostRepository::new(pool);
|
||||||
let boost = Boost {
|
let boost = Boost {
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
};
|
};
|
||||||
repo.save(&boost).await.unwrap();
|
repo.save(&boost).await.unwrap();
|
||||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn unboost(pool: sqlx::PgPool) {
|
async fn unboost(pool: sqlx::PgPool) {
|
||||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||||
let repo = PgBoostRepository::new(pool);
|
let repo = PgBoostRepository::new(pool);
|
||||||
let boost = Boost {
|
let boost = Boost {
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
repo.save(&boost).await.unwrap();
|
repo.save(&boost).await.unwrap();
|
||||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
feed::PageParams,
|
feed::PageParams,
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||||
let urepo = PgUserRepository::new(pool.clone());
|
let urepo = PgUserRepository::new(pool.clone());
|
||||||
let trepo = PgThoughtRepository::new(pool.clone());
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
let u = User::new_local(
|
let u = User::new_local(
|
||||||
@@ -20,43 +20,49 @@
|
|||||||
PasswordHash("h".into()),
|
PasswordHash("h".into()),
|
||||||
);
|
);
|
||||||
urepo.save(&u).await.unwrap();
|
urepo.save(&u).await.unwrap();
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
u.id.clone(),
|
user_id: u.id.clone(),
|
||||||
Content::new_local(content).unwrap(),
|
content: Content::new_local(content).unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(u, t)
|
(u, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.query(&FeedQuery::public(
|
.query(&FeedQuery::public(
|
||||||
PageParams { page: 1, per_page: 20 },
|
PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
None,
|
None,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.total, 1);
|
assert_eq!(result.total, 1);
|
||||||
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
||||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.query(&FeedQuery::search(
|
.query(&FeedQuery::search(
|
||||||
"hello world",
|
"hello world",
|
||||||
PageParams { page: 1, per_page: 20 },
|
PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
None,
|
None,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
@@ -66,4 +72,4 @@
|
|||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user;
|
use crate::test_helpers::seed_user;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::value_objects::*;
|
use domain::value_objects::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_find_follow(pool: sqlx::PgPool) {
|
async fn save_and_find_follow(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgFollowRepository::new(pool);
|
let repo = PgFollowRepository::new(pool);
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
repo.save(&follow).await.unwrap();
|
repo.save(&follow).await.unwrap();
|
||||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
assert_eq!(found.state, FollowState::Accepted);
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn update_state(pool: sqlx::PgPool) {
|
async fn update_state(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgFollowRepository::new(pool);
|
let repo = PgFollowRepository::new(pool);
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
assert_eq!(found.state, FollowState::Accepted);
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgFollowRepository::new(pool);
|
let repo = PgFollowRepository::new(pool);
|
||||||
@@ -55,4 +55,4 @@
|
|||||||
repo.save(&follow).await.unwrap();
|
repo.save(&follow).await.unwrap();
|
||||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||||
assert_eq!(ids, vec![bob.id]);
|
assert_eq!(ids, vec![bob.id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
pub mod activitypub;
|
pub mod activitypub;
|
||||||
pub mod engagement;
|
|
||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod block;
|
pub mod block;
|
||||||
pub mod boost;
|
pub mod boost;
|
||||||
mod db_error;
|
mod db_error;
|
||||||
|
pub mod engagement;
|
||||||
pub mod failed_event;
|
pub mod failed_event;
|
||||||
pub mod outbox;
|
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub mod like;
|
pub mod like;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
|
pub mod outbox;
|
||||||
pub mod remote_actor;
|
pub mod remote_actor;
|
||||||
pub mod remote_actor_connections;
|
pub mod remote_actor_connections;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user_and_thought;
|
use crate::test_helpers::seed_user_and_thought;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::value_objects::*;
|
use domain::value_objects::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn like_and_count(pool: sqlx::PgPool) {
|
async fn like_and_count(pool: sqlx::PgPool) {
|
||||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||||
let repo = PgLikeRepository::new(pool);
|
let repo = PgLikeRepository::new(pool);
|
||||||
let like = Like {
|
let like = Like {
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
};
|
};
|
||||||
repo.save(&like).await.unwrap();
|
repo.save(&like).await.unwrap();
|
||||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn unlike(pool: sqlx::PgPool) {
|
async fn unlike(pool: sqlx::PgPool) {
|
||||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||||
let repo = PgLikeRepository::new(pool);
|
let repo = PgLikeRepository::new(pool);
|
||||||
let like = Like {
|
let like = Like {
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
repo.save(&like).await.unwrap();
|
repo.save(&like).await.unwrap();
|
||||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers;
|
use crate::test_helpers;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{notification::NotificationKind, user::User},
|
models::{notification::NotificationKind, user::User},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_list(pool: sqlx::PgPool) {
|
async fn save_and_list(pool: sqlx::PgPool) {
|
||||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgNotificationRepository::new(pool);
|
let repo = PgNotificationRepository::new(pool);
|
||||||
@@ -34,10 +34,10 @@
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(page.total, 1);
|
assert_eq!(page.total, 1);
|
||||||
assert!(!page.items[0].read);
|
assert!(!page.items[0].read);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn mark_all_read(pool: sqlx::PgPool) {
|
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgNotificationRepository::new(pool);
|
let repo = PgNotificationRepository::new(pool);
|
||||||
@@ -64,4 +64,4 @@
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(page.items[0].read);
|
assert!(page.items[0].read);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
use domain::ports::{ThoughtRepository, UserWriter};
|
use domain::ports::{ThoughtRepository, UserWriter};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||||
let repo = PgTagRepository::new(pool);
|
let repo = PgTagRepository::new(pool);
|
||||||
let t1 = repo.find_or_create("rust").await.unwrap();
|
let t1 = repo.find_or_create("rust").await.unwrap();
|
||||||
let t2 = repo.find_or_create("rust").await.unwrap();
|
let t2 = repo.find_or_create("rust").await.unwrap();
|
||||||
assert_eq!(t1.id, t2.id);
|
assert_eq!(t1.id, t2.id);
|
||||||
assert_eq!(t1.name, "rust");
|
assert_eq!(t1.name, "rust");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn attach_and_list(pool: sqlx::PgPool) {
|
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||||
let urepo = PgUserRepository::new(pool.clone());
|
let urepo = PgUserRepository::new(pool.clone());
|
||||||
let trepo = PgThoughtRepository::new(pool.clone());
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
let u = User::new_local(
|
let u = User::new_local(
|
||||||
@@ -29,15 +29,15 @@
|
|||||||
PasswordHash("h".into()),
|
PasswordHash("h".into()),
|
||||||
);
|
);
|
||||||
urepo.save(&u).await.unwrap();
|
urepo.save(&u).await.unwrap();
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
u.id.clone(),
|
user_id: u.id.clone(),
|
||||||
Content::new_local("hi").unwrap(),
|
content: Content::new_local("hi").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
let repo = PgTagRepository::new(pool);
|
let repo = PgTagRepository::new(pool);
|
||||||
let tag = repo.find_or_create("greetings").await.unwrap();
|
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||||
@@ -45,4 +45,4 @@
|
|||||||
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
||||||
assert_eq!(tags.len(), 1);
|
assert_eq!(tags.len(), 1);
|
||||||
assert_eq!(tags[0].name, "greetings");
|
assert_eq!(tags[0].name, "greetings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{ThoughtRepository, UserWriter},
|
ports::{ThoughtRepository, UserWriter},
|
||||||
@@ -23,15 +23,15 @@ pub async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User
|
|||||||
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
|
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||||
let user = seed_user(pool, "alice", "alice@ex.com").await;
|
let user = seed_user(pool, "alice", "alice@ex.com").await;
|
||||||
let trepo = PgThoughtRepository::new(pool.clone());
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
Content::new_local("hi").unwrap(),
|
content: Content::new_local("hi").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(user, t)
|
(user, t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,90 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user;
|
use crate::test_helpers::seed_user;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{NewThought, Thought, Visibility},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_find_thought(pool: sqlx::PgPool) {
|
async fn save_and_find_thought(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool, "alice", "alice@ex.com").await;
|
let user = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let repo = PgThoughtRepository::new(pool);
|
let repo = PgThoughtRepository::new(pool);
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
Content::new_local("hello world").unwrap(),
|
content: Content::new_local("hello world").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
repo.save(&t).await.unwrap();
|
repo.save(&t).await.unwrap();
|
||||||
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
||||||
assert_eq!(found.content.as_str(), "hello world");
|
assert_eq!(found.content.as_str(), "hello world");
|
||||||
assert!(found.local);
|
assert!(found.local);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn delete_thought(pool: sqlx::PgPool) {
|
async fn delete_thought(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgThoughtRepository::new(pool);
|
let repo = PgThoughtRepository::new(pool);
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
Content::new_local("bye").unwrap(),
|
content: Content::new_local("bye").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
repo.save(&t).await.unwrap();
|
repo.save(&t).await.unwrap();
|
||||||
repo.delete(&t.id, &user.id).await.unwrap();
|
repo.delete(&t.id, &user.id).await.unwrap();
|
||||||
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgThoughtRepository::new(pool);
|
let repo = PgThoughtRepository::new(pool);
|
||||||
let t = Thought::new_local(
|
let t = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("secret").unwrap(),
|
content: Content::new_local("secret").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
repo.save(&t).await.unwrap();
|
repo.save(&t).await.unwrap();
|
||||||
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||||
assert!(matches!(err, DomainError::NotFound));
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
||||||
let repo = PgThoughtRepository::new(pool);
|
let repo = PgThoughtRepository::new(pool);
|
||||||
let root = Thought::new_local(
|
let root = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
Content::new_local("root").unwrap(),
|
content: Content::new_local("root").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
let reply = Thought::new_local(
|
let reply = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
Content::new_local("reply").unwrap(),
|
content: Content::new_local("reply").unwrap(),
|
||||||
Some(root.id.clone()),
|
in_reply_to_id: Some(root.id.clone()),
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
repo.save(&root).await.unwrap();
|
repo.save(&root).await.unwrap();
|
||||||
repo.save(&reply).await.unwrap();
|
repo.save(&reply).await.unwrap();
|
||||||
let thread = repo.get_thread(&root.id).await.unwrap();
|
let thread = repo.get_thread(&root.id).await.unwrap();
|
||||||
assert_eq!(thread.len(), 2);
|
assert_eq!(thread.len(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::user::PgUserRepository;
|
use crate::user::PgUserRepository;
|
||||||
use domain::ports::UserWriter;
|
use domain::ports::UserWriter;
|
||||||
use domain::{models::user::User, value_objects::*};
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||||
let repo = PgUserRepository::new(pool.clone());
|
let repo = PgUserRepository::new(pool.clone());
|
||||||
let u = User::new_local(
|
let u = User::new_local(
|
||||||
UserId::new(),
|
UserId::new(),
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
);
|
);
|
||||||
repo.save(&u).await.unwrap();
|
repo.save(&u).await.unwrap();
|
||||||
u
|
u
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
|
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgTopFriendRepository::new(pool);
|
let repo = PgTopFriendRepository::new(pool);
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
assert_eq!(friends.len(), 1);
|
assert_eq!(friends.len(), 1);
|
||||||
assert_eq!(friends[0].0.position, 1);
|
assert_eq!(friends[0].0.position, 1);
|
||||||
assert_eq!(friends[0].1.username.as_str(), "bob");
|
assert_eq!(friends[0].1.username.as_str(), "bob");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn replace_top_friends(pool: sqlx::PgPool) {
|
async fn replace_top_friends(pool: sqlx::PgPool) {
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||||
@@ -44,4 +44,4 @@
|
|||||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||||
assert_eq!(friends.len(), 1);
|
assert_eq!(friends.len(), 1);
|
||||||
assert_eq!(friends[0].1.username.as_str(), "carol");
|
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::feed::{PageParams, Paginated, UserSummary},
|
models::feed::{PageParams, Paginated, UserSummary},
|
||||||
models::user::User,
|
models::user::{UpdateProfileInput, User},
|
||||||
ports::{UserReader, UserWriter},
|
ports::{UserReader, UserWriter},
|
||||||
value_objects::{Email, PasswordHash, UserId, Username},
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
};
|
};
|
||||||
@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
|
|||||||
.into_domain()
|
.into_domain()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
async fn list_paginated(
|
||||||
|
&self,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
|
|||||||
following_count: r.following_count,
|
following_count: r.following_count,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
||||||
@@ -195,18 +203,19 @@ impl UserReader for PgUserRepository {
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
|
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
let rows = sqlx::query_as::<_, UserRow>(
|
let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
|
||||||
&format!("{USER_SELECT} WHERE id = ANY($1)")
|
|
||||||
)
|
|
||||||
.bind(&uuids[..])
|
.bind(&uuids[..])
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(rows.into_iter().map(|r| {
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
let user = User::from(r);
|
let user = User::from(r);
|
||||||
(user.id.clone(), user)
|
(user.id.clone(), user)
|
||||||
}).collect())
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,21 +265,17 @@ impl UserWriter for PgUserRepository {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
|
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
|
||||||
)
|
)
|
||||||
.bind(user_id.as_uuid())
|
.bind(user_id.as_uuid())
|
||||||
.bind(display_name)
|
.bind(input.display_name)
|
||||||
.bind(bio)
|
.bind(input.bio)
|
||||||
.bind(avatar_url)
|
.bind(input.avatar_url)
|
||||||
.bind(header_url)
|
.bind(input.header_url)
|
||||||
.bind(custom_css)
|
.bind(input.custom_css)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::{models::user::User, value_objects::*};
|
use domain::{
|
||||||
|
models::user::{UpdateProfileInput, User},
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_find_by_id(pool: sqlx::PgPool) {
|
async fn save_and_find_by_id(pool: sqlx::PgPool) {
|
||||||
let repo = PgUserRepository::new(pool);
|
let repo = PgUserRepository::new(pool);
|
||||||
let user = User::new_local(
|
let user = User::new_local(
|
||||||
UserId::new(),
|
UserId::new(),
|
||||||
@@ -14,20 +17,20 @@
|
|||||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||||
assert_eq!(found.username.as_str(), "alice");
|
assert_eq!(found.username.as_str(), "alice");
|
||||||
assert_eq!(found.email.as_str(), "alice@ex.com");
|
assert_eq!(found.email.as_str(), "alice@ex.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
||||||
let repo = PgUserRepository::new(pool);
|
let repo = PgUserRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.find_by_username(&Username::new("ghost").unwrap())
|
.find_by_username(&Username::new("ghost").unwrap())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn find_by_email(pool: sqlx::PgPool) {
|
async fn find_by_email(pool: sqlx::PgPool) {
|
||||||
let repo = PgUserRepository::new(pool);
|
let repo = PgUserRepository::new(pool);
|
||||||
let user = User::new_local(
|
let user = User::new_local(
|
||||||
UserId::new(),
|
UserId::new(),
|
||||||
@@ -41,10 +44,10 @@
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(found.is_some());
|
assert!(found.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
|
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
|
||||||
let repo = PgUserRepository::new(pool);
|
let repo = PgUserRepository::new(pool);
|
||||||
let user = User::new_local(
|
let user = User::new_local(
|
||||||
UserId::new(),
|
UserId::new(),
|
||||||
@@ -55,15 +58,15 @@
|
|||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
repo.update_profile(
|
repo.update_profile(
|
||||||
&user.id,
|
&user.id,
|
||||||
Some("Charlie".into()),
|
UpdateProfileInput {
|
||||||
Some("bio".into()),
|
display_name: Some("Charlie".into()),
|
||||||
None,
|
bio: Some("bio".into()),
|
||||||
None,
|
..Default::default()
|
||||||
None,
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||||
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
||||||
assert_eq!(found.bio.as_deref(), Some("bio"));
|
assert_eq!(found.bio.as_deref(), Some("bio"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
activitypub-base = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use activitypub_base::{ActorApUrls, OutboundFederationPort};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crate::testing::TestApRepo;
|
use crate::testing::TestApRepo;
|
||||||
|
use activitypub::{ActorApUrls, OutboundFederationPort};
|
||||||
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{NewThought, Thought, Visibility},
|
||||||
models::user::User,
|
models::user::User,
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
|
|||||||
self.announced.lock().unwrap().push(ap_id.to_string());
|
self.announced.lock().unwrap().push(ap_id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn broadcast_undo_announce(
|
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
_: &UserId,
|
|
||||||
ap_id: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn broadcast_like(
|
async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
_: &UserId,
|
|
||||||
ap_id: &str,
|
|
||||||
_: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
self.liked.lock().unwrap().push(ap_id.to_string());
|
self.liked.lock().unwrap().push(ap_id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -101,15 +92,15 @@ fn alice() -> User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn local_thought(author_id: UserId) -> Thought {
|
fn local_thought(author_id: UserId) -> Thought {
|
||||||
Thought::new_local(
|
Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
author_id,
|
user_id: author_id,
|
||||||
Content::new_local("hello").unwrap(),
|
content: Content::new_local("hello").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||||
@@ -123,7 +114,11 @@ fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
|
fn svc_with_ap(
|
||||||
|
store: &TestStore,
|
||||||
|
ap_repo: TestApRepo,
|
||||||
|
spy: Arc<SpyPort>,
|
||||||
|
) -> FederationEventService {
|
||||||
FederationEventService {
|
FederationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
users: Arc::new(store.clone()),
|
users: Arc::new(store.clone()),
|
||||||
@@ -280,15 +275,15 @@ async fn boost_of_remote_thought_announces_remote_ap_id() {
|
|||||||
async fn direct_thought_created_does_not_broadcast() {
|
async fn direct_thought_created_does_not_broadcast() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("private").unwrap(),
|
content: Content::new_local("private").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Direct,
|
visibility: Visibility::Direct,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.users.lock().unwrap().push(alice.clone());
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
@@ -309,15 +304,15 @@ async fn direct_thought_created_does_not_broadcast() {
|
|||||||
async fn followers_only_thought_does_not_broadcast_publicly() {
|
async fn followers_only_thought_does_not_broadcast_publicly() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("for followers").unwrap(),
|
content: Content::new_local("for followers").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Followers,
|
visibility: Visibility::Followers,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.users.lock().unwrap().push(alice.clone());
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use super::*;
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
notification::NotificationKind,
|
notification::NotificationKind,
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
@@ -24,15 +24,15 @@ async fn like_creates_notification_for_thought_author() {
|
|||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let bob_id = UserId::new();
|
let bob_id = UserId::new();
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("hello").unwrap(),
|
content: Content::new_local("hello").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
@@ -54,15 +54,15 @@ async fn like_creates_notification_for_thought_author() {
|
|||||||
async fn self_like_creates_no_notification() {
|
async fn self_like_creates_no_notification() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("hello").unwrap(),
|
content: Content::new_local("hello").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
@@ -103,15 +103,15 @@ async fn reply_creates_notification_for_original_author() {
|
|||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let bob_id = UserId::new();
|
let bob_id = UserId::new();
|
||||||
let original = Thought::new_local(
|
let original = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("original").unwrap(),
|
content: Content::new_local("original").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.thoughts.lock().unwrap().push(original.clone());
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
@@ -133,15 +133,15 @@ async fn reply_creates_notification_for_original_author() {
|
|||||||
async fn self_reply_creates_no_notification() {
|
async fn self_reply_creates_no_notification() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let original = Thought::new_local(
|
let original = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("original").unwrap(),
|
content: Content::new_local("original").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.thoughts.lock().unwrap().push(original.clone());
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
@@ -161,15 +161,15 @@ async fn self_reply_creates_no_notification() {
|
|||||||
async fn self_boost_creates_no_notification() {
|
async fn self_boost_creates_no_notification() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
Content::new_local("hello").unwrap(),
|
content: Content::new_local("hello").unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
);
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/// Test helpers for application-layer tests that need activitypub_base traits.
|
/// Test helpers for application-layer tests that need activitypub traits.
|
||||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -95,22 +95,11 @@ impl ActivityPubRepository for TestApRepo {
|
|||||||
}
|
}
|
||||||
async fn accept_note(
|
async fn accept_note(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &str,
|
_input: activitypub::AcceptNoteInput<'_>,
|
||||||
_author_id: &UserId,
|
|
||||||
_content: &str,
|
|
||||||
_published: chrono::DateTime<chrono::Utc>,
|
|
||||||
_sensitive: bool,
|
|
||||||
_content_warning: Option<String>,
|
|
||||||
_visibility: &str,
|
|
||||||
_in_reply_to: Option<&str>,
|
|
||||||
) -> Result<ThoughtId, DomainError> {
|
) -> Result<ThoughtId, DomainError> {
|
||||||
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
||||||
}
|
}
|
||||||
async fn apply_note_update(
|
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
_ap_id: &str,
|
|
||||||
_new_content: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
|||||||
@@ -34,19 +34,14 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
let hash = hasher.hash(&input.password).await?;
|
let hash = hasher.hash(&input.password).await?;
|
||||||
let user = User::new_local(UserId::new(), username, email, hash);
|
let user = User::new_local(UserId::new(), username, email, hash);
|
||||||
users
|
users.save(&user).await.map_err(|e| match e {
|
||||||
.save(&user)
|
|
||||||
.await
|
|
||||||
.map_err(|e| match e {
|
|
||||||
DomainError::UniqueViolation { field: "username" } => {
|
DomainError::UniqueViolation { field: "username" } => {
|
||||||
DomainError::Conflict("username taken".into())
|
DomainError::Conflict("username taken".into())
|
||||||
}
|
}
|
||||||
DomainError::UniqueViolation { field: "email" } => {
|
DomainError::UniqueViolation { field: "email" } => {
|
||||||
DomainError::Conflict("email taken".into())
|
DomainError::Conflict("email taken".into())
|
||||||
}
|
}
|
||||||
DomainError::UniqueViolation { .. } => {
|
DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
|
||||||
DomainError::Conflict("already exists".into())
|
|
||||||
}
|
|
||||||
other => other,
|
other => other,
|
||||||
})?;
|
})?;
|
||||||
events
|
events
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
models::{
|
||||||
|
feed::{PageParams, Paginated, UserSummary},
|
||||||
|
user::{UpdateProfileInput, User},
|
||||||
|
},
|
||||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||||
testing::{NoOpEventPublisher, TestStore},
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
value_objects::{Email, PasswordHash, UserId, Username},
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
@@ -19,10 +22,7 @@ impl UserReader for ConflictOnSaveStore {
|
|||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
self.0.find_by_id(id).await
|
self.0.find_by_id(id).await
|
||||||
}
|
}
|
||||||
async fn find_by_username(
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
&self,
|
|
||||||
username: &Username,
|
|
||||||
) -> Result<Option<User>, DomainError> {
|
|
||||||
self.0.find_by_username(username).await
|
self.0.find_by_username(username).await
|
||||||
}
|
}
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
@@ -34,10 +34,16 @@ impl UserReader for ConflictOnSaveStore {
|
|||||||
async fn count(&self) -> Result<i64, DomainError> {
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
self.0.count().await
|
self.0.count().await
|
||||||
}
|
}
|
||||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
async fn list_paginated(
|
||||||
|
&self,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||||
self.0.list_paginated(page).await
|
self.0.list_paginated(page).await
|
||||||
}
|
}
|
||||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
async fn find_by_ids(
|
||||||
|
&self,
|
||||||
|
ids: &[UserId],
|
||||||
|
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||||
self.0.find_by_ids(ids).await
|
self.0.find_by_ids(ids).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,15 +56,9 @@ impl UserWriter for ConflictOnSaveStore {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.0
|
self.0.update_profile(user_id, input).await
|
||||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +67,7 @@ impl UserReader for EmailConflictOnSaveStore {
|
|||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
self.0.find_by_id(id).await
|
self.0.find_by_id(id).await
|
||||||
}
|
}
|
||||||
async fn find_by_username(
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
&self,
|
|
||||||
username: &Username,
|
|
||||||
) -> Result<Option<User>, DomainError> {
|
|
||||||
self.0.find_by_username(username).await
|
self.0.find_by_username(username).await
|
||||||
}
|
}
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
@@ -82,10 +79,16 @@ impl UserReader for EmailConflictOnSaveStore {
|
|||||||
async fn count(&self) -> Result<i64, DomainError> {
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
self.0.count().await
|
self.0.count().await
|
||||||
}
|
}
|
||||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
async fn list_paginated(
|
||||||
|
&self,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||||
self.0.list_paginated(page).await
|
self.0.list_paginated(page).await
|
||||||
}
|
}
|
||||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
async fn find_by_ids(
|
||||||
|
&self,
|
||||||
|
ids: &[UserId],
|
||||||
|
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||||
self.0.find_by_ids(ids).await
|
self.0.find_by_ids(ids).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,15 +101,9 @@ impl UserWriter for EmailConflictOnSaveStore {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.0
|
self.0.update_profile(user_id, input).await
|
||||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use activitypub_base::ActivityPubRepository;
|
use activitypub::ActivityPubRepository;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
@@ -7,9 +7,9 @@ use domain::{
|
|||||||
remote_actor::RemoteActor,
|
remote_actor::RemoteActor,
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
EventPublisher, FederationActionPort, FederationFollowPort,
|
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
|
||||||
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
|
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
|
||||||
FollowRepository, RemoteActorConnectionRepository, UserReader,
|
RemoteActorConnectionRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||||
};
|
};
|
||||||
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
|
let result = feed
|
||||||
|
.query(&FeedQuery::user(
|
||||||
|
author_id,
|
||||||
|
page.clone(),
|
||||||
|
viewer_id.cloned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
if let Some(outbox_url) = actor.outbox_url {
|
if let Some(outbox_url) = actor.outbox_url {
|
||||||
let _ = scheduler
|
let _ = scheduler
|
||||||
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ pub async fn get_home_feed(
|
|||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||||
following_ids.push(user_id.clone());
|
following_ids.push(user_id.clone());
|
||||||
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
|
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const MAX_TOP_FRIENDS: usize = 8;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{top_friend::TopFriend, user::User},
|
models::{
|
||||||
|
top_friend::TopFriend,
|
||||||
|
user::{UpdateProfileInput, User},
|
||||||
|
},
|
||||||
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
|
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
|
||||||
value_objects::{UserId, Username},
|
value_objects::{UserId, Username},
|
||||||
};
|
};
|
||||||
@@ -41,27 +44,13 @@ pub async fn get_user_by_id_or_username(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn update_profile(
|
pub async fn update_profile(
|
||||||
users: &dyn UserWriter,
|
users: &dyn UserWriter,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
users
|
users.update_profile(user_id, input).await?;
|
||||||
.update_profile(
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
bio,
|
|
||||||
avatar_url,
|
|
||||||
header_url,
|
|
||||||
custom_css,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
events
|
events
|
||||||
.publish(&DomainEvent::ProfileUpdated {
|
.publish(&DomainEvent::ProfileUpdated {
|
||||||
user_id: user_id.clone(),
|
user_id: user_id.clone(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
@@ -22,15 +22,19 @@ async fn like_and_unlike() {
|
|||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = user("alice");
|
let alice = user("alice");
|
||||||
let tid = ThoughtId::new();
|
let tid = ThoughtId::new();
|
||||||
store.thoughts.lock().unwrap().push(Thought::new_local(
|
store
|
||||||
tid.clone(),
|
.thoughts
|
||||||
alice.id.clone(),
|
.lock()
|
||||||
Content::new_local("hi").unwrap(),
|
.unwrap()
|
||||||
None,
|
.push(Thought::new_local(NewThought {
|
||||||
Visibility::Public,
|
id: tid.clone(),
|
||||||
None,
|
user_id: alice.id.clone(),
|
||||||
false,
|
content: Content::new_local("hi").unwrap(),
|
||||||
));
|
in_reply_to_id: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
content_warning: None,
|
||||||
|
sensitive: false,
|
||||||
|
}));
|
||||||
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||||
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
||||||
unlike_thought(&store, &store, &alice.id, &tid)
|
unlike_thought(&store, &store, &alice.id, &tid)
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ use domain::{
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
feed::{EngagementStats, FeedEntry},
|
feed::{EngagementStats, FeedEntry},
|
||||||
thought::{Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
|
},
|
||||||
|
ports::{
|
||||||
|
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
|
||||||
|
UserReader,
|
||||||
},
|
},
|
||||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
|
||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,15 +46,15 @@ pub async fn create_thought(
|
|||||||
Some("direct") => Visibility::Direct,
|
Some("direct") => Visibility::Direct,
|
||||||
_ => Visibility::Public,
|
_ => Visibility::Public,
|
||||||
};
|
};
|
||||||
let thought = Thought::new_local(
|
let thought = Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
input.user_id,
|
user_id: input.user_id,
|
||||||
content.clone(),
|
content: content.clone(),
|
||||||
input.in_reply_to_id.clone(),
|
in_reply_to_id: input.in_reply_to_id.clone(),
|
||||||
visibility,
|
visibility,
|
||||||
input.content_warning,
|
content_warning: input.content_warning,
|
||||||
input.sensitive,
|
sensitive: input.sensitive,
|
||||||
);
|
});
|
||||||
thoughts.save(&thought).await?;
|
thoughts.save(&thought).await?;
|
||||||
|
|
||||||
// Extract and attach hashtags from content.
|
// Extract and attach hashtags from content.
|
||||||
@@ -132,11 +135,23 @@ pub async fn get_thought_view(
|
|||||||
.find_by_id(&thought.user_id)
|
.find_by_id(&thought.user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(DomainError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
|
let mut map = engagement
|
||||||
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
|
.get_for_thoughts(std::slice::from_ref(id), viewer)
|
||||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
.await?;
|
||||||
);
|
let (stats, viewer_ctx) = map.remove(id).unwrap_or((
|
||||||
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
|
EngagementStats {
|
||||||
|
like_count: 0,
|
||||||
|
boost_count: 0,
|
||||||
|
reply_count: 0,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
Ok(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats,
|
||||||
|
viewer: viewer_ctx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
|
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
|
||||||
@@ -169,10 +184,20 @@ pub async fn get_thread_views(
|
|||||||
.get(&thought.user_id)
|
.get(&thought.user_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(DomainError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
|
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
|
||||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
EngagementStats {
|
||||||
);
|
like_count: 0,
|
||||||
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
|
boost_count: 0,
|
||||||
|
reply_count: 0,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
entries.push(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats,
|
||||||
|
viewer: viewer_ctx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ async fn create_thought_saves_and_stages_outbox_event() {
|
|||||||
let outbox = TestOutbox::default();
|
let outbox = TestOutbox::default();
|
||||||
let u = user();
|
let u = user();
|
||||||
store.users.lock().unwrap().push(u.clone());
|
store.users.lock().unwrap().push(u.clone());
|
||||||
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
|
let out = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
&outbox,
|
||||||
|
input(u.id.clone()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out.thought.content.as_str(), "hello");
|
assert_eq!(out.thought.content.as_str(), "hello");
|
||||||
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
|
|||||||
|
|
||||||
let staged = outbox.staged();
|
let staged = outbox.staged();
|
||||||
assert_eq!(staged.len(), 1);
|
assert_eq!(staged.len(), 1);
|
||||||
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
|
assert!(
|
||||||
|
matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -82,7 +91,13 @@ async fn delete_own_thought_succeeds() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id)
|
delete_thought(
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
&NoOpOutboxWriter,
|
||||||
|
&out.thought.id,
|
||||||
|
&u.id,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(store.thoughts.lock().unwrap().is_empty());
|
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||||
@@ -113,7 +128,13 @@ async fn delete_other_thought_returns_not_found() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id)
|
let err = delete_thought(
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
&NoOpOutboxWriter,
|
||||||
|
&out.thought.id,
|
||||||
|
&bob.id,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, DomainError::NotFound));
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
@@ -124,7 +145,14 @@ async fn edit_thought_changes_content_and_emits_event() {
|
|||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = user();
|
let alice = user();
|
||||||
store.users.lock().unwrap().push(alice.clone());
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone()))
|
let out = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
&NoOpOutboxWriter,
|
||||||
|
input(alice.id.clone()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tid = out.thought.id.clone();
|
let tid = out.thought.id.clone();
|
||||||
@@ -194,7 +222,7 @@ async fn create_reply_sets_in_reply_to_id() {
|
|||||||
|
|
||||||
// enrichment_tests (combined from second cfg(test) block)
|
// enrichment_tests (combined from second cfg(test) block)
|
||||||
|
|
||||||
use domain::models::thought::{Thought, Visibility};
|
use domain::models::thought::{NewThought, Thought, Visibility};
|
||||||
use domain::ports::{ThoughtRepository, UserWriter};
|
use domain::ports::{ThoughtRepository, UserWriter};
|
||||||
|
|
||||||
fn make_user() -> User {
|
fn make_user() -> User {
|
||||||
@@ -207,24 +235,28 @@ fn make_user() -> User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_thought(user_id: UserId) -> Thought {
|
fn make_thought(user_id: UserId) -> Thought {
|
||||||
Thought::new_local(
|
Thought::new_local(NewThought {
|
||||||
ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
user_id,
|
user_id,
|
||||||
Content::new_local(String::from("hello")).unwrap(),
|
content: Content::new_local(String::from("hello")).unwrap(),
|
||||||
None,
|
in_reply_to_id: None,
|
||||||
Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
None,
|
content_warning: None,
|
||||||
false,
|
sensitive: false,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_thought_view_returns_feed_entry() {
|
async fn get_thought_view_returns_feed_entry() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let user = make_user();
|
let user = make_user();
|
||||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
<TestStore as UserWriter>::save(&store, &user)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let thought = make_thought(user.id.clone());
|
let thought = make_thought(user.id.clone());
|
||||||
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
|
<TestStore as ThoughtRepository>::save(&store, &thought)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
|
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
|
||||||
.await
|
.await
|
||||||
@@ -248,19 +280,25 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
|
|||||||
async fn get_thread_views_batches_correctly() {
|
async fn get_thread_views_batches_correctly() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let user = make_user();
|
let user = make_user();
|
||||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
<TestStore as UserWriter>::save(&store, &user)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let root = make_thought(user.id.clone());
|
let root = make_thought(user.id.clone());
|
||||||
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
|
<TestStore as ThoughtRepository>::save(&store, &root)
|
||||||
let reply = Thought::new_local(
|
.await
|
||||||
ThoughtId::new(),
|
.unwrap();
|
||||||
user.id.clone(),
|
let reply = Thought::new_local(NewThought {
|
||||||
Content::new_local(String::from("reply")).unwrap(),
|
id: ThoughtId::new(),
|
||||||
Some(root.id.clone()),
|
user_id: user.id.clone(),
|
||||||
Visibility::Public,
|
content: Content::new_local(String::from("reply")).unwrap(),
|
||||||
None,
|
in_reply_to_id: Some(root.id.clone()),
|
||||||
false,
|
visibility: Visibility::Public,
|
||||||
);
|
content_warning: None,
|
||||||
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
|
sensitive: false,
|
||||||
|
});
|
||||||
|
<TestStore as ThoughtRepository>::save(&store, &reply)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let entries = get_thread_views(&store, &store, &store, &root.id, None)
|
let entries = get_thread_views(&store, &store, &store, &root.id, None)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ postgres = { workspace = true }
|
|||||||
postgres-search = { workspace = true }
|
postgres-search = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
|
||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ use async_trait::async_trait;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
||||||
use activitypub_base::service::ActivityPubService;
|
use k_ap::ActivityPubService;
|
||||||
use auth::ApiKeyServiceImpl;
|
use auth::ApiKeyServiceImpl;
|
||||||
use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}};
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
ports::{EventPublisher, OutboxWriter},
|
||||||
|
};
|
||||||
use event_transport::EventPublisherAdapter;
|
use event_transport::EventPublisherAdapter;
|
||||||
use nats::NatsTransport;
|
use nats::NatsTransport;
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
@@ -23,7 +27,7 @@ use crate::config::Config;
|
|||||||
/// Everything the binary needs to start serving.
|
/// Everything the binary needs to start serving.
|
||||||
pub struct Infrastructure {
|
pub struct Infrastructure {
|
||||||
pub state: AppState,
|
pub state: AppState,
|
||||||
pub ap_service: Arc<ActivityPubService>,
|
pub ap_service: Arc<ApFederationAdapter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoOpEventPublisher;
|
struct NoOpEventPublisher;
|
||||||
@@ -68,8 +72,10 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. ActivityPub federation
|
// 3. ActivityPub federation
|
||||||
let ap_service = Arc::new(
|
let connections_repo =
|
||||||
ActivityPubService::new(
|
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||||
|
let raw_ap_service = Arc::new(
|
||||||
|
ActivityPubService::builder(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
Arc::new(PostgresApUserRepository::new(
|
Arc::new(PostgresApUserRepository::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
@@ -82,14 +88,15 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||||
)),
|
)),
|
||||||
cfg.base_url.clone(),
|
cfg.base_url.clone(),
|
||||||
cfg.allow_registration,
|
|
||||||
"thoughts".to_string(),
|
|
||||||
cfg.debug,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
.allow_registration(cfg.allow_registration)
|
||||||
|
.software_name("thoughts")
|
||||||
|
.debug(cfg.debug)
|
||||||
|
.build()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to build ActivityPubService"),
|
.expect("Failed to build ActivityPubService"),
|
||||||
);
|
);
|
||||||
|
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
|
||||||
|
|
||||||
// 4. Application state
|
// 4. Application state
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -129,9 +136,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
||||||
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
|
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
|
||||||
api_key_auth: Arc::new(ApiKeyServiceImpl::new(
|
api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
|
||||||
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
postgres::api_key::PgApiKeyRepository::new(pool.clone()),
|
||||||
)),
|
))),
|
||||||
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
|
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,24 +46,26 @@ impl Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct NewThought {
|
||||||
|
pub id: ThoughtId,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub content: Content,
|
||||||
|
pub in_reply_to_id: Option<ThoughtId>,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Thought {
|
impl Thought {
|
||||||
pub fn new_local(
|
pub fn new_local(p: NewThought) -> Self {
|
||||||
id: ThoughtId,
|
|
||||||
user_id: UserId,
|
|
||||||
content: Content,
|
|
||||||
in_reply_to_id: Option<ThoughtId>,
|
|
||||||
visibility: Visibility,
|
|
||||||
content_warning: Option<String>,
|
|
||||||
sensitive: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id: p.id,
|
||||||
user_id,
|
user_id: p.user_id,
|
||||||
content,
|
content: p.content,
|
||||||
in_reply_to_id,
|
in_reply_to_id: p.in_reply_to_id,
|
||||||
visibility,
|
visibility: p.visibility,
|
||||||
content_warning,
|
content_warning: p.content_warning,
|
||||||
sensitive,
|
sensitive: p.sensitive,
|
||||||
local: true,
|
local: true,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct UpdateProfileInput {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: UserId,
|
pub id: UserId,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
tag::Tag,
|
tag::Tag,
|
||||||
thought::Thought,
|
thought::Thought,
|
||||||
top_friend::TopFriend,
|
top_friend::TopFriend,
|
||||||
user::User,
|
user::{UpdateProfileInput, User},
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
|
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
|
||||||
@@ -58,7 +58,8 @@ pub trait UserReader: Send + Sync {
|
|||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
||||||
async fn count(&self) -> Result<i64, DomainError>;
|
async fn count(&self) -> Result<i64, DomainError>;
|
||||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError>;
|
async fn list_paginated(&self, page: PageParams)
|
||||||
|
-> Result<Paginated<UserSummary>, DomainError>;
|
||||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
|
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +69,7 @@ pub trait UserWriter: Send + Sync {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,19 +350,43 @@ pub struct FeedQuery {
|
|||||||
|
|
||||||
impl FeedQuery {
|
impl FeedQuery {
|
||||||
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self {
|
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self {
|
||||||
Self { scope: FeedScope::Home { following_ids }, page, viewer_id: Some(viewer_id) }
|
Self {
|
||||||
|
scope: FeedScope::Home { following_ids },
|
||||||
|
page,
|
||||||
|
viewer_id: Some(viewer_id),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self {
|
pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
Self { scope: FeedScope::Public, page, viewer_id }
|
Self {
|
||||||
|
scope: FeedScope::Public,
|
||||||
|
page,
|
||||||
|
viewer_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
Self { scope: FeedScope::Tag { tag_name: tag_name.into() }, page, viewer_id }
|
Self {
|
||||||
|
scope: FeedScope::Tag {
|
||||||
|
tag_name: tag_name.into(),
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
viewer_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
Self { scope: FeedScope::User { user_id }, page, viewer_id }
|
Self {
|
||||||
|
scope: FeedScope::User { user_id },
|
||||||
|
page,
|
||||||
|
viewer_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
Self { scope: FeedScope::Search { query: query.into() }, page, viewer_id }
|
Self {
|
||||||
|
scope: FeedScope::Search {
|
||||||
|
query: query.into(),
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
viewer_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +413,6 @@ pub trait SearchPort: Send + Sync {
|
|||||||
) -> Result<Paginated<User>, DomainError>;
|
) -> Result<Paginated<User>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FederationSchedulerPort: Send + Sync {
|
pub trait FederationSchedulerPort: Send + Sync {
|
||||||
async fn schedule_actor_posts_fetch(
|
async fn schedule_actor_posts_fetch(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::{
|
|||||||
tag::Tag,
|
tag::Tag,
|
||||||
thought::Thought,
|
thought::Thought,
|
||||||
top_friend::TopFriend,
|
top_friend::TopFriend,
|
||||||
user::User,
|
user::{UpdateProfileInput, User},
|
||||||
},
|
},
|
||||||
ports::*,
|
ports::*,
|
||||||
value_objects::{
|
value_objects::{
|
||||||
@@ -83,17 +83,30 @@ impl UserReader for TestStore {
|
|||||||
.count() as i64)
|
.count() as i64)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
async fn list_paginated(
|
||||||
|
&self,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||||
let all = self.list_with_stats().await?;
|
let all = self.list_with_stats().await?;
|
||||||
let total = all.len() as i64;
|
let total = all.len() as i64;
|
||||||
let start = page.offset() as usize;
|
let start = page.offset() as usize;
|
||||||
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect();
|
let items: Vec<UserSummary> = all
|
||||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
.into_iter()
|
||||||
|
.skip(start)
|
||||||
|
.take(page.limit() as usize)
|
||||||
|
.collect();
|
||||||
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
||||||
let g = self.users.lock().unwrap();
|
let g = self.users.lock().unwrap();
|
||||||
let map = g.iter()
|
let map = g
|
||||||
|
.iter()
|
||||||
.filter(|u| ids.contains(&u.id))
|
.filter(|u| ids.contains(&u.id))
|
||||||
.map(|u| (u.id.clone(), u.clone()))
|
.map(|u| (u.id.clone(), u.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -112,11 +125,7 @@ impl UserWriter for TestStore {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
input: UpdateProfileInput,
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if let Some(u) = self
|
if let Some(u) = self
|
||||||
.users
|
.users
|
||||||
@@ -125,11 +134,11 @@ impl UserWriter for TestStore {
|
|||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|u| &u.id == user_id)
|
.find(|u| &u.id == user_id)
|
||||||
{
|
{
|
||||||
u.display_name = display_name;
|
u.display_name = input.display_name;
|
||||||
u.bio = bio;
|
u.bio = input.bio;
|
||||||
u.avatar_url = avatar_url;
|
u.avatar_url = input.avatar_url;
|
||||||
u.header_url = header_url;
|
u.header_url = input.header_url;
|
||||||
u.custom_css = custom_css;
|
u.custom_css = input.custom_css;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -294,7 +303,16 @@ impl EngagementRepository for TestStore {
|
|||||||
&self,
|
&self,
|
||||||
thought_ids: &[ThoughtId],
|
thought_ids: &[ThoughtId],
|
||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<HashMap<ThoughtId, (crate::models::feed::EngagementStats, Option<crate::models::feed::ViewerContext>)>, DomainError> {
|
) -> Result<
|
||||||
|
HashMap<
|
||||||
|
ThoughtId,
|
||||||
|
(
|
||||||
|
crate::models::feed::EngagementStats,
|
||||||
|
Option<crate::models::feed::ViewerContext>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
DomainError,
|
||||||
|
> {
|
||||||
use crate::models::feed::{EngagementStats, ViewerContext};
|
use crate::models::feed::{EngagementStats, ViewerContext};
|
||||||
let likes = self.likes.lock().unwrap();
|
let likes = self.likes.lock().unwrap();
|
||||||
let boosts = self.boosts.lock().unwrap();
|
let boosts = self.boosts.lock().unwrap();
|
||||||
@@ -304,12 +322,29 @@ impl EngagementRepository for TestStore {
|
|||||||
for tid in thought_ids {
|
for tid in thought_ids {
|
||||||
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64;
|
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64;
|
||||||
let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64;
|
let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64;
|
||||||
let reply_count = thoughts.iter().filter(|t| t.in_reply_to_id.as_ref() == Some(tid)).count() as i64;
|
let reply_count = thoughts
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.in_reply_to_id.as_ref() == Some(tid))
|
||||||
|
.count() as i64;
|
||||||
let viewer = viewer_id.map(|vid| ViewerContext {
|
let viewer = viewer_id.map(|vid| ViewerContext {
|
||||||
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid),
|
liked: likes
|
||||||
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid),
|
.iter()
|
||||||
|
.any(|l| &l.thought_id == tid && &l.user_id == vid),
|
||||||
|
boosted: boosts
|
||||||
|
.iter()
|
||||||
|
.any(|b| &b.thought_id == tid && &b.user_id == vid),
|
||||||
});
|
});
|
||||||
result.insert(tid.clone(), (EngagementStats { like_count, boost_count, reply_count }, viewer));
|
result.insert(
|
||||||
|
tid.clone(),
|
||||||
|
(
|
||||||
|
EngagementStats {
|
||||||
|
like_count,
|
||||||
|
boost_count,
|
||||||
|
reply_count,
|
||||||
|
},
|
||||||
|
viewer,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -763,7 +798,10 @@ impl RemoteActorConnectionRepository for TestStore {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for TestStore {
|
impl FeedRepository for TestStore {
|
||||||
async fn query(&self, _q: &crate::ports::FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn query(
|
||||||
|
&self,
|
||||||
|
_q: &crate::ports::FeedQuery,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: vec![],
|
items: vec![],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
activitypub-base = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
api-types = { workspace = true }
|
api-types = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ use api_types::{
|
|||||||
responses::{ApiKeyResponse, CreatedApiKeyResponse},
|
responses::{ApiKeyResponse, CreatedApiKeyResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
|
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
use crate::{
|
use crate::{deps_struct, errors::ApiError, extractors::Deps};
|
||||||
deps_struct,
|
|
||||||
errors::ApiError,
|
|
||||||
extractors::Deps,
|
|
||||||
};
|
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{LoginRequest, RegisterRequest},
|
requests::{LoginRequest, RegisterRequest},
|
||||||
responses::{AuthResponse, ErrorResponse, UserResponse},
|
responses::{AuthResponse, ErrorResponse, UserResponse},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
handlers::feed::to_thought_response,
|
handlers::feed::to_thought_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
use activitypub::ActivityPubRepository;
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::PaginationQuery,
|
requests::PaginationQuery,
|
||||||
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
|
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
|
||||||
@@ -15,7 +16,6 @@ use axum::{
|
|||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use activitypub_base::ActivityPubRepository;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams,
|
models::feed::PageParams,
|
||||||
ports::{
|
ports::{
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams,
|
models::feed::PageParams,
|
||||||
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
|
ports::{
|
||||||
|
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
|
||||||
|
TagRepository, UserRepository,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
deps_struct!(FeedDeps {
|
deps_struct!(FeedDeps {
|
||||||
@@ -224,7 +227,10 @@ pub async fn user_thoughts_handler(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?;
|
let result = d
|
||||||
|
.feed
|
||||||
|
.query(&FeedQuery::user(user.id.clone(), page, viewer))
|
||||||
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"page": result.page,
|
"page": result.page,
|
||||||
@@ -241,7 +247,10 @@ pub async fn get_popular_tags(
|
|||||||
.get("limit")
|
.get("limit")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
||||||
let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?;
|
let tags = d
|
||||||
|
.tags
|
||||||
|
.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize))
|
||||||
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -268,7 +277,10 @@ pub async fn tag_thoughts_handler(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?;
|
let result = d
|
||||||
|
.feed
|
||||||
|
.query(&FeedQuery::tag(&tag_name, page, viewer))
|
||||||
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tag": tag_name,
|
"tag": tag_name,
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ use application::use_cases::notifications::{
|
|||||||
count_unread_notifications, list_notifications as uc_list_notifications,
|
count_unread_notifications, list_notifications as uc_list_notifications,
|
||||||
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
|
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::handlers::auth::to_user_response;
|
||||||
use crate::{
|
use crate::{
|
||||||
deps_struct,
|
deps_struct,
|
||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
@@ -5,14 +6,9 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use api_types::requests::SetTopFriendsRequest;
|
use api_types::requests::SetTopFriendsRequest;
|
||||||
use api_types::responses::TopFriendsResponse;
|
use api_types::responses::TopFriendsResponse;
|
||||||
use crate::handlers::auth::to_user_response;
|
|
||||||
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
||||||
use application::use_cases::social::*;
|
use application::use_cases::social::*;
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::{
|
ports::{
|
||||||
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
||||||
|
|||||||
@@ -9,18 +9,16 @@ use api_types::{
|
|||||||
responses::ErrorResponse,
|
responses::ErrorResponse,
|
||||||
};
|
};
|
||||||
use application::use_cases::thoughts::{
|
use application::use_cases::thoughts::{
|
||||||
create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view,
|
create_thought, delete_thought, edit_thought, get_thought_view, get_thread_views,
|
||||||
CreateThoughtInput,
|
CreateThoughtInput,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::feed::{EngagementStats, FeedEntry, ViewerContext},
|
models::feed::{EngagementStats, FeedEntry, ViewerContext},
|
||||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
|
ports::{
|
||||||
|
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
|
||||||
|
UserRepository,
|
||||||
|
},
|
||||||
value_objects::ThoughtId,
|
value_objects::ThoughtId,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -74,8 +72,15 @@ pub async fn post_thought(
|
|||||||
let entry = FeedEntry {
|
let entry = FeedEntry {
|
||||||
thought: out.thought,
|
thought: out.thought,
|
||||||
author,
|
author,
|
||||||
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 },
|
stats: EngagementStats {
|
||||||
viewer: Some(ViewerContext { liked: false, boosted: false }),
|
like_count: 0,
|
||||||
|
boost_count: 0,
|
||||||
|
reply_count: 0,
|
||||||
|
},
|
||||||
|
viewer: Some(ViewerContext {
|
||||||
|
liked: false,
|
||||||
|
boosted: false,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
|
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
|
||||||
}
|
}
|
||||||
@@ -101,7 +106,9 @@ pub async fn get_thought_handler(
|
|||||||
viewer.as_ref(),
|
viewer.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap()))
|
Ok(Json(
|
||||||
|
serde_json::to_value(to_thought_response(&entry)).unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -119,7 +126,14 @@ pub async fn delete_thought_handler(
|
|||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?;
|
delete_thought(
|
||||||
|
&*d.thoughts,
|
||||||
|
&*d.events,
|
||||||
|
&*d.outbox,
|
||||||
|
&ThoughtId::from_uuid(id),
|
||||||
|
&uid,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::{
|
||||||
EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository,
|
models::user::UpdateProfileInput,
|
||||||
|
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -96,11 +97,13 @@ pub async fn patch_profile(
|
|||||||
&*d.users,
|
&*d.users,
|
||||||
&*d.events,
|
&*d.events,
|
||||||
&uid,
|
&uid,
|
||||||
body.display_name,
|
UpdateProfileInput {
|
||||||
body.bio,
|
display_name: body.display_name,
|
||||||
body.avatar_url,
|
bio: body.bio,
|
||||||
body.header_url,
|
avatar_url: body.avatar_url,
|
||||||
body.custom_css,
|
header_url: body.header_url,
|
||||||
|
custom_css: body.custom_css,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let user = fetch_user(&*d.users, &uid).await?;
|
let user = fetch_user(&*d.users, &uid).await?;
|
||||||
@@ -191,9 +194,7 @@ pub async fn get_users(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_count(
|
pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
Deps(d): Deps<UsersDeps>,
|
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
||||||
let count = d.users.count().await?;
|
let count = d.users.count().await?;
|
||||||
Ok(Json(serde_json::json!({ "count": count })))
|
Ok(Json(serde_json::json!({ "count": count })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use activitypub_base::ActivityPubRepository;
|
use activitypub::ActivityPubRepository;
|
||||||
use domain::ports::*;
|
use domain::ports::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -68,22 +68,11 @@ impl ActivityPubRepository for NoOpApRepo {
|
|||||||
}
|
}
|
||||||
async fn accept_note(
|
async fn accept_note(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &str,
|
_input: activitypub::AcceptNoteInput<'_>,
|
||||||
_author_id: &UserId,
|
|
||||||
_content: &str,
|
|
||||||
_published: chrono::DateTime<chrono::Utc>,
|
|
||||||
_sensitive: bool,
|
|
||||||
_content_warning: Option<String>,
|
|
||||||
_visibility: &str,
|
|
||||||
_in_reply_to: Option<&str>,
|
|
||||||
) -> Result<ThoughtId, DomainError> {
|
) -> Result<ThoughtId, DomainError> {
|
||||||
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
||||||
}
|
}
|
||||||
async fn apply_note_update(
|
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
_ap_id: &str,
|
|
||||||
_new_content: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ application = { workspace = true }
|
|||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
event-payload = { workspace = true }
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
postgres = { workspace = true }
|
postgres = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use postgres::failed_event::PgFailedEventStore;
|
use postgres::failed_event::PgFailedEventStore;
|
||||||
|
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
||||||
use activitypub_base::ActivityPubService;
|
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
||||||
|
use k_ap::ActivityPubService;
|
||||||
use application::services::{FederationEventService, NotificationEventService};
|
use application::services::{FederationEventService, NotificationEventService};
|
||||||
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
|
||||||
use domain::ports::EventPublisher;
|
use domain::ports::EventPublisher;
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
@@ -38,8 +39,10 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
|||||||
));
|
));
|
||||||
|
|
||||||
// ActivityPub service (for federation fan-out)
|
// ActivityPub service (for federation fan-out)
|
||||||
let ap_service = Arc::new(
|
let connections_repo_worker =
|
||||||
ActivityPubService::new(
|
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||||
|
let raw_ap_service = Arc::new(
|
||||||
|
ActivityPubService::builder(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
Arc::new(PostgresApUserRepository::new(
|
Arc::new(PostgresApUserRepository::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
@@ -51,15 +54,14 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
|||||||
None,
|
None,
|
||||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||||
)),
|
)),
|
||||||
base_url.to_string(),
|
base_url,
|
||||||
false,
|
|
||||||
"thoughts".to_string(),
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
.software_name("thoughts")
|
||||||
|
.build()
|
||||||
.await
|
.await
|
||||||
.expect("ActivityPubService build failed"),
|
.expect("ActivityPubService build failed"),
|
||||||
);
|
);
|
||||||
|
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo_worker));
|
||||||
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||||
let ap_repo_worker =
|
let ap_repo_worker =
|
||||||
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
||||||
|
|||||||
@@ -43,13 +43,19 @@ async fn main() {
|
|||||||
match result {
|
match result {
|
||||||
Ok(envelope) => {
|
Ok(envelope) => {
|
||||||
let event = &envelope.event;
|
let event = &envelope.event;
|
||||||
tracing::debug!(?event, "received event");
|
let event_type = event_payload::EventPayload::from(event).subject();
|
||||||
|
tracing::info!(
|
||||||
|
event_type,
|
||||||
|
delivery = envelope.delivery_count,
|
||||||
|
"received event"
|
||||||
|
);
|
||||||
|
|
||||||
let n = infra.handlers.notification.handle(event).await;
|
let n = infra.handlers.notification.handle(event).await;
|
||||||
let f = infra.handlers.federation.handle(event).await;
|
let f = infra.handlers.federation.handle(event).await;
|
||||||
|
|
||||||
if n.is_ok() && f.is_ok() {
|
if n.is_ok() && f.is_ok() {
|
||||||
(envelope.ack)();
|
(envelope.ack)();
|
||||||
|
tracing::info!(event_type, "event handled ok");
|
||||||
} else {
|
} else {
|
||||||
if let Err(e) = &n {
|
if let Err(e) = &n {
|
||||||
tracing::error!("notification handler: {e}");
|
tracing::error!("notification handler: {e}");
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ impl OutboxRelay {
|
|||||||
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
|
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}");
|
tracing::error!(
|
||||||
|
seq = row.seq,
|
||||||
|
event_type = row.event_type,
|
||||||
|
"outbox: failed to deserialize payload: {e}"
|
||||||
|
);
|
||||||
// Mark delivered to avoid blocking; investigate manually.
|
// Mark delivered to avoid blocking; investigate manually.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE outbox_events \
|
"UPDATE outbox_events \
|
||||||
@@ -75,7 +79,10 @@ impl OutboxRelay {
|
|||||||
let domain_event = match DomainEvent::try_from(payload) {
|
let domain_event = match DomainEvent::try_from(payload) {
|
||||||
Ok(ev) => ev,
|
Ok(ev) => ev,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}");
|
tracing::error!(
|
||||||
|
seq = row.seq,
|
||||||
|
"outbox: failed to convert to DomainEvent: {e}"
|
||||||
|
);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE outbox_events \
|
"UPDATE outbox_events \
|
||||||
SET delivered = true, delivered_at = now() \
|
SET delivered = true, delivered_at = now() \
|
||||||
@@ -100,7 +107,11 @@ impl OutboxRelay {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered");
|
tracing::info!(
|
||||||
|
seq = row.seq,
|
||||||
|
event_type = row.event_type,
|
||||||
|
"outbox: delivered"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");
|
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");
|
||||||
|
|||||||
138
foundation.md
138
foundation.md
@@ -1,138 +0,0 @@
|
|||||||
Project Thoughts: Foundational Plan & System Architecture
|
|
||||||
|
|
||||||
1. Vision & Core Philosophy
|
|
||||||
Project Vision: To create a decentralized social media platform that prioritizes genuine user connection, creative self-expression, and a user-controlled experience. Thoughts is a deliberate departure from algorithm-driven feeds and corporate-controlled online spaces.
|
|
||||||
|
|
||||||
Core Philosophy: We are building a digital "third place" reminiscent of the early internet, where the focus is on community and individuality. The platform's aesthetic and function are guided by the "Frutiger Aero" design language—optimistic, humanistic, and clear.
|
|
||||||
|
|
||||||
Tagline: Your Space, Your Friends, Your Feed.
|
|
||||||
|
|
||||||
2. Guiding Principles & Rules to Follow
|
|
||||||
These are the non-negotiable rules that will guide all development and feature decisions.
|
|
||||||
|
|
||||||
Chronological Above All: The main feed will always be in reverse chronological order. There will be no algorithmic sorting or "while you were away" features.
|
|
||||||
|
|
||||||
User in Control: Users, not the platform, decide what they see by choosing who to follow. There will be no algorithmic content or user suggestions.
|
|
||||||
|
|
||||||
Radical Self-Expression: Profiles are a canvas. Users will be given significant freedom to customize the look and feel of their personal space.
|
|
||||||
|
|
||||||
Open and Federated: The platform must be a citizen of the Fediverse. ActivityPub integration is a primary, not secondary, feature.
|
|
||||||
|
|
||||||
Performance by Default: The user experience should be fast and lightweight, minimizing client-side JavaScript and optimizing for quick page loads.
|
|
||||||
|
|
||||||
API-First Design: The backend is the core service. The frontend is simply the first and primary client of a well-documented, public-facing API. This ensures that third-party developers have the same power as the official web client.
|
|
||||||
|
|
||||||
3. System Architecture
|
|
||||||
This section details the technical structure of the platform. Your concern about running a Rust backend with a Next.js frontend is a common one. The standard and most effective solution is to run them as two separate services that communicate with each other, orchestrated by a reverse proxy.
|
|
||||||
|
|
||||||
3.1. Architecture Diagram (High Level)
|
|
||||||
+----------------+ +---------------------+ +------------------------+ +-------------------+
|
|
||||||
| User's | <--> | Reverse Proxy | <--> | Next.js Frontend | | |
|
|
||||||
| Browser | | (Nginx / Caddy) | | (Web Server) | | |
|
|
||||||
+----------------+ +---------------------+ +------------------------+ | PostgreSQL |
|
|
||||||
| | | Database |
|
|
||||||
| (Routes /api/\*) | | |
|
|
||||||
| | +-------------------+
|
|
||||||
v |
|
|
||||||
+------------------------+ <--------------------------------------+
|
|
||||||
| Rust Backend |
|
|
||||||
| (API & ActivityPub) |
|
|
||||||
+------------------------+
|
|
||||||
|
|
||||||
3.2. Component Breakdown
|
|
||||||
Frontend: Next.js
|
|
||||||
|
|
||||||
Role: Acts as the primary client for the Rust API. It is responsible for rendering the user interface.
|
|
||||||
|
|
||||||
Justification: Using Next.js for Server-Side Rendering (SSR) gives us the best of both worlds: fast initial page loads with minimal client-side JavaScript (fulfilling the "lightweight" requirement) and a modern, component-based development experience. It will handle user sessions and render pages by fetching data from the Rust API during the request-response cycle on the server.
|
|
||||||
|
|
||||||
Backend: Rust
|
|
||||||
|
|
||||||
Role: The core of the application. It's a stateless API server that handles all business logic, user authentication, database operations, and ActivityPub federation logic.
|
|
||||||
|
|
||||||
Recommended Framework: axum. It is modern, modular, and integrates seamlessly with the tokio ecosystem, making it a robust choice for building high-performance APIs.
|
|
||||||
|
|
||||||
API Specification: We will use the OpenAPI 3.0 standard to define and document the REST API.
|
|
||||||
|
|
||||||
Database: PostgreSQL
|
|
||||||
|
|
||||||
Role: The single source of truth for all user data, posts, follows, etc.
|
|
||||||
|
|
||||||
Justification: PostgreSQL is a powerful, reliable, and open-source relational database with excellent support in the Rust ecosystem (via libraries like sqlx and diesel). It provides the structure needed for a social platform.
|
|
||||||
|
|
||||||
Deployment: Docker & Docker Compose
|
|
||||||
|
|
||||||
Role: To containerize each component (Frontend, Backend, Database, Reverse Proxy) for consistent, reproducible, and easy deployments.
|
|
||||||
|
|
||||||
docker-compose.yml Structure: The final docker-compose.yml file will define four services:
|
|
||||||
|
|
||||||
thoughts-backend: Builds and runs the Rust application.
|
|
||||||
|
|
||||||
thoughts-frontend: Builds and runs the Next.js application.
|
|
||||||
|
|
||||||
database: Runs the official PostgreSQL image, with a persistent volume for data.
|
|
||||||
|
|
||||||
proxy: Runs an Nginx or Caddy container configured to route traffic. Requests to yourdomain.com/api/\* will be forwarded to the Rust service, and all other requests will go to the Next.js service.
|
|
||||||
|
|
||||||
4. Features & Acceptance Criteria
|
|
||||||
This outlines the minimum viable product (MVP) features.
|
|
||||||
|
|
||||||
Feature
|
|
||||||
|
|
||||||
Description
|
|
||||||
|
|
||||||
Acceptance Criteria
|
|
||||||
|
|
||||||
User Accounts
|
|
||||||
|
|
||||||
Users can sign up, log in, log out. Usernames are unique.
|
|
||||||
|
|
||||||
I can successfully create an account. I can log in with my credentials and am issued a session. I can log out, which invalidates my session.
|
|
||||||
|
|
||||||
Customizable Profiles
|
|
||||||
|
|
||||||
Each user has a public profile page (/username). Profiles have a display name, bio, avatar, header, and a "Top Friends" list.
|
|
||||||
|
|
||||||
I can edit my profile details. I can upload an avatar and header image. I can select up to 8 other users to display in my "Top Friends" list.
|
|
||||||
|
|
||||||
Profile CSS
|
|
||||||
|
|
||||||
A field in the profile settings allows users to input custom CSS to style their profile page. This CSS is sanitized to prevent security risks.
|
|
||||||
|
|
||||||
I can add custom CSS to my profile. When another user visits my profile, they see my custom styling applied. Malicious CSS (e.g., url() with external scripts) is stripped out.
|
|
||||||
|
|
||||||
Posting Thoughts
|
|
||||||
|
|
||||||
Users can publish short text posts (max 128 characters) to their profile. Posts can include hashtags (e.g., #frutigeraero).
|
|
||||||
|
|
||||||
I can publish a post. It appears on my profile and in the feeds of my followers. Hashtags are clickable links. I receive an error if my post is over 128 characters.
|
|
||||||
|
|
||||||
Following System
|
|
||||||
|
|
||||||
Users can follow and unfollow other users on the platform.
|
|
||||||
|
|
||||||
When I visit a user's profile, I see a "Follow" button. Clicking it adds them to my follow list. Clicking "Unfollow" removes them.
|
|
||||||
|
|
||||||
The Main Feed
|
|
||||||
|
|
||||||
The homepage for a logged-in user. It displays posts from all followed users in reverse chronological order.
|
|
||||||
|
|
||||||
My feed contains posts from everyone I follow. The newest post is always at the top. The feed contains no posts from users I don't follow.
|
|
||||||
|
|
||||||
Tag Discovery
|
|
||||||
|
|
||||||
A "Popular Tags" section on the main page lists tags that are currently trending. Clicking a tag shows a public feed of all posts with that tag.
|
|
||||||
|
|
||||||
The popular tags list updates based on recent post activity. Clicking a tag takes me to a page showing all posts with that tag, sorted chronologically.
|
|
||||||
|
|
||||||
ActivityPub (MVP)
|
|
||||||
|
|
||||||
A user's profile is exposed as an ActivityPub actor (e.g., @username@thoughts.social).
|
|
||||||
|
|
||||||
A user on another Fediverse platform (e.g., Mastodon) can search for my profile and follow me. My new posts are federated and appear on their timeline.
|
|
||||||
|
|
||||||
Public API (MVP)
|
|
||||||
|
|
||||||
A simple, token-based API for publishing thoughts.
|
|
||||||
|
|
||||||
I can generate an API key from my settings. Using this key and curl or another tool, I can successfully publish a "thought" to my profile. API documentation is available.
|
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
/* Frutiger Aero Gradients */
|
/* Frutiger Aero Gradients */
|
||||||
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
||||||
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
||||||
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
|
--gradient-fa-card:
|
||||||
hsl(var(--card)) 100%;
|
180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%;
|
||||||
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
|
--gradient-fa-gloss:
|
||||||
rgba(255, 255, 255, 0) 100%;
|
135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%;
|
||||||
|
|
||||||
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
@@ -177,17 +177,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
background-image: url("/background.avif");
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-attachment: fixed;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glossy-effect::before {
|
.glossy-effect::before {
|
||||||
@@ -312,3 +311,165 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Frutiger Aero interaction keyframes ── */
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: translateX(-4px) rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateX(4px) rotate(1.5deg);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: translateX(-3px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(3px) rotate(1deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(-1px) rotate(-0.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatBob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerAero {
|
||||||
|
0% {
|
||||||
|
background-position: -400px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 400px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.animate-slide-down {
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideDown 0.22s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.45s ease-out;
|
||||||
|
}
|
||||||
|
.animate-fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-float-bob {
|
||||||
|
animation: floatBob 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aero-tinted shimmer for skeleton loaders */
|
||||||
|
.shimmer-aero {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(96, 165, 250, 0.12) 25%,
|
||||||
|
rgba(96, 165, 250, 0.3) 50%,
|
||||||
|
rgba(96, 165, 250, 0.12) 75%
|
||||||
|
);
|
||||||
|
background-size: 800px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: shimmerAero 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widget title icon badges */
|
||||||
|
.widget-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.widget-icon-blue {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(37, 99, 235, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.widget-icon-green {
|
||||||
|
background: linear-gradient(135deg, #6ee7b7, #10b981);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(16, 185, 129, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.widget-icon-purple {
|
||||||
|
background: linear-gradient(135deg, #c4b5fd, #7c3aed);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(124, 58, 237, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landing page ambient orbs */
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(40px);
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient avatar fallback */
|
||||||
|
.avatar-gradient {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #34d399);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px white,
|
||||||
|
0 0 0 3.5px rgba(59, 130, 246, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-slide-down {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-shake {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-fade-out {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-float-bob {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.shimmer-aero {
|
||||||
|
animation: none;
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import Image from "next/image";
|
||||||
import InstallPrompt from "@/components/install-prompt";
|
import InstallPrompt from "@/components/install-prompt";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -52,6 +53,16 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${frutiger.className} antialiased`}>
|
<body className={`${frutiger.className} antialiased`}>
|
||||||
|
<div className="fixed inset-0 -z-10">
|
||||||
|
<Image
|
||||||
|
src="/bg1.avif"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
quality={85}
|
||||||
|
className="object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count";
|
|||||||
import { PaginationNav } from "@/components/pagination-nav";
|
import { PaginationNav } from "@/components/pagination-nav";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
|
import {
|
||||||
|
ProfileSkeleton,
|
||||||
|
TagsSkeleton,
|
||||||
|
CountSkeleton,
|
||||||
|
} from "@/components/loading-skeleton";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Home",
|
title: "Home",
|
||||||
@@ -86,9 +90,7 @@ async function FeedPage({
|
|||||||
</header>
|
</header>
|
||||||
<ThoughtForm />
|
<ThoughtForm />
|
||||||
|
|
||||||
<div className="block lg:hidden space-y-6">
|
<div className="block lg:hidden space-y-6">{sidebar}</div>
|
||||||
{sidebar}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{thoughtThreads.map((thought) => (
|
{thoughtThreads.map((thought) => (
|
||||||
@@ -99,7 +101,13 @@ async function FeedPage({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{thoughtThreads.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
|
<EmptyState
|
||||||
|
emoji="💭"
|
||||||
|
title="Your feed is quiet"
|
||||||
|
message="Your feed is empty. Follow some users to see their thoughts!"
|
||||||
|
ctaLabel="Discover people ✨"
|
||||||
|
ctaHref="/users/all"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PaginationNav
|
<PaginationNav
|
||||||
@@ -110,9 +118,7 @@ async function FeedPage({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div className="sticky top-20 space-y-6">{sidebar}</div>
|
||||||
{sidebar}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,28 +127,112 @@ async function FeedPage({
|
|||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||||
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
|
{/* Ambient orbs */}
|
||||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
|
<div
|
||||||
|
className="orb"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
height: 280,
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, #ffffff 0%, #87ceeb 60%, transparent 100%)",
|
||||||
|
top: "-80px",
|
||||||
|
left: "-60px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="orb"
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, #b2f5ea 0%, #48bb78 60%, transparent 100%)",
|
||||||
|
bottom: "-40px",
|
||||||
|
right: "5%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="orb"
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, #e0f2fe 0%, #38bdf8 60%, transparent 100%)",
|
||||||
|
top: "35%",
|
||||||
|
left: "65%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero card */}
|
||||||
|
<div
|
||||||
|
className="container mx-auto max-w-lg p-4 sm:p-6 text-center relative z-10"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.28)",
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
WebkitBackdropFilter: "blur(20px)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.55)",
|
||||||
|
borderRadius: "20px",
|
||||||
|
boxShadow:
|
||||||
|
"0 8px 32px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Gloss sweep */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: "55%",
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(255,255,255,0.38) 0%, transparent 100%)",
|
||||||
|
borderRadius: "20px 20px 0 0",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<h1
|
<h1
|
||||||
className="text-5xl font-bold"
|
className="text-5xl font-bold relative"
|
||||||
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
style={{
|
||||||
|
textShadow:
|
||||||
|
"0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Welcome to Thoughts
|
Welcome to Thoughts
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-3 relative">
|
||||||
Throwback to the golden age of microblogging.
|
A federated social network for short-form thoughts.
|
||||||
|
<br />
|
||||||
|
Connect with the Fediverse.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex justify-center gap-4">
|
|
||||||
<Button asChild>
|
<div className="mt-8 flex justify-center gap-4 relative">
|
||||||
|
<Button asChild className="px-7">
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" asChild>
|
<Button asChild variant="secondary" className="px-7">
|
||||||
<Link href="/register">Register</Link>
|
<Link href="/register">Register</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fediverse badge */}
|
||||||
|
<div className="mt-5 relative flex justify-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs text-muted-foreground"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.3)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
|
||||||
|
style={{ boxShadow: "0 0 4px #34d399" }}
|
||||||
|
/>
|
||||||
|
Works with Mastodon, Pixelfed & more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ export default async function RemoteActorPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actor = actorResult.value;
|
const actor = actorResult.value;
|
||||||
const posts =
|
const postsData = postsResult.status === "fulfilled" ? postsResult.value : null;
|
||||||
postsResult.status === "fulfilled" ? postsResult.value.items : [];
|
const posts = postsData?.items ?? [];
|
||||||
|
const totalPages = postsData
|
||||||
|
? Math.ceil(postsData.total / postsData.per_page)
|
||||||
|
: 1;
|
||||||
const me =
|
const me =
|
||||||
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
|
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
|
||||||
const following =
|
const following =
|
||||||
@@ -77,7 +80,9 @@ export default async function RemoteActorPage({
|
|||||||
<RemoteUserProfile
|
<RemoteUserProfile
|
||||||
key={actor.url}
|
key={actor.url}
|
||||||
actor={actor}
|
actor={actor}
|
||||||
|
handle={handle}
|
||||||
initialPosts={posts}
|
initialPosts={posts}
|
||||||
|
initialTotalPages={totalPages}
|
||||||
me={me}
|
me={me}
|
||||||
initialFollowed={initialFollowed}
|
initialFollowed={initialFollowed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
<RemoteUserCard actor={remoteActor} />
|
<RemoteUserCard actor={remoteActor} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={`No user found at ${query}`} />
|
<EmptyState emoji="🔍" title="No results" message={`No user found at ${query}`} />
|
||||||
)
|
)
|
||||||
) : results ? (
|
) : results ? (
|
||||||
<Tabs defaultValue="thoughts" className="w-full">
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
@@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message="No results found or an error occurred." />
|
<EmptyState emoji="🔍" title="No results" message="No results found or an error occurred." />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{thoughtThreads.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<EmptyState message="No thoughts found for this tag." />
|
<EmptyState emoji="🏷" title="No thoughts here yet" message="No thoughts found for this tag." />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ import { FollowButton } from "@/components/follow-button";
|
|||||||
import { TopFriends } from "@/components/top-friends";
|
import { TopFriends } from "@/components/top-friends";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ProfileSkeleton } from "@/components/loading-skeleton";
|
import { ProfileSkeleton } from "@/components/loading-skeleton";
|
||||||
import { buildThoughtThreads } from "@/lib/utils";
|
import { UserThoughtsList } from "@/components/user-thoughts-list";
|
||||||
import { ThoughtThread } from "@/components/thought-thread";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -95,9 +94,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const user = userResult.value;
|
const user = userResult.value;
|
||||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
const thoughts =
|
const thoughtsData = thoughtsResult.status === "fulfilled" ? thoughtsResult.value : null;
|
||||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
|
const thoughts = thoughtsData?.items ?? [];
|
||||||
const thoughtThreads = buildThoughtThreads(thoughts);
|
const totalPages = thoughtsData
|
||||||
|
? Math.ceil(thoughtsData.total / thoughtsData.per_page)
|
||||||
|
: 1;
|
||||||
|
|
||||||
const localFollowersCount =
|
const localFollowersCount =
|
||||||
followersResult.status === "fulfilled"
|
followersResult.status === "fulfilled"
|
||||||
@@ -194,7 +195,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</p>
|
</p>
|
||||||
{fediverseHandle && (
|
{fediverseHandle && (
|
||||||
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all">
|
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all break-all">
|
||||||
{fediverseHandle}
|
{fediverseHandle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -262,16 +263,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="thoughts" className="space-y-4">
|
<TabsContent value="thoughts" className="space-y-4">
|
||||||
{thoughtThreads.map((thought) => (
|
<UserThoughtsList
|
||||||
<ThoughtThread
|
username={username}
|
||||||
key={thought.id}
|
initialThoughts={thoughts}
|
||||||
thought={thought}
|
totalPages={totalPages}
|
||||||
currentUser={me}
|
me={me}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
{thoughtThreads.length === 0 && (
|
|
||||||
<EmptyState message="This user hasn't posted any public thoughts yet." />
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<TabsContent value="federation">
|
<TabsContent value="federation">
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
message: string
|
emoji?: string;
|
||||||
className?: string
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ message, className }: EmptyStateProps) {
|
export function EmptyState({
|
||||||
|
emoji = "💭",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
|
className = "",
|
||||||
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
|
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
|
||||||
{message}
|
<span className="text-4xl animate-float-bob select-none" aria-hidden="true">
|
||||||
</p>
|
{emoji}
|
||||||
)
|
</span>
|
||||||
|
{title && (
|
||||||
|
<p className="font-bold text-base text-foreground text-shadow-sm">{title}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">{message}</p>
|
||||||
|
{ctaLabel && ctaHref && (
|
||||||
|
<Link
|
||||||
|
href={ctaHref}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-5 py-2 rounded-full text-sm font-bold text-white fa-gradient-blue shadow-fa-md glossy-effect relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useOptimistic } from "react"
|
import { useOptimistic, useRef } from "react"
|
||||||
import { followUser, unfollowUser } from "@/app/actions/social"
|
import { followUser, unfollowUser } from "@/app/actions/social"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -11,24 +11,93 @@ interface FollowButtonProps {
|
|||||||
isInitiallyFollowing: boolean
|
isInitiallyFollowing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BURST_COLORS = ["#2563eb", "#06b6d4", "#10b981", "#f59e0b", "#a855f7", "#ef4444"]
|
||||||
|
|
||||||
|
function burstParticles(canvas: HTMLCanvasElement) {
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const cx = canvas.width / 2
|
||||||
|
const cy = canvas.height / 2
|
||||||
|
const particles = Array.from({ length: 14 }, (_, i) => {
|
||||||
|
const angle = (i / 14) * Math.PI * 2
|
||||||
|
const speed = 2.5 + Math.random() * 2
|
||||||
|
return {
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
r: 3 + Math.random() * 3,
|
||||||
|
color: BURST_COLORS[i % BURST_COLORS.length],
|
||||||
|
life: 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let rafId: number
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
if (!canvas.isConnected) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx!.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
let alive = false
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.vy += 0.08
|
||||||
|
p.life -= 0.03
|
||||||
|
if (p.life > 0) {
|
||||||
|
alive = true
|
||||||
|
ctx!.globalAlpha = p.life
|
||||||
|
ctx!.fillStyle = p.color
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
|
ctx!.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx!.globalAlpha = 1
|
||||||
|
if (alive) {
|
||||||
|
rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
||||||
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
const next = !optimisticFollowing
|
const next = !optimisticFollowing
|
||||||
setOptimisticFollowing(next)
|
setOptimisticFollowing(next)
|
||||||
|
|
||||||
|
if (next && canvasRef.current) {
|
||||||
|
burstParticles(canvasRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await (next ? followUser(username) : unfollowUser(username))
|
await (next ? followUser(username) : unfollowUser(username))
|
||||||
} catch {
|
} catch {
|
||||||
setOptimisticFollowing(!next) // revert
|
setOptimisticFollowing(!next)
|
||||||
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={160}
|
||||||
|
height={80}
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
variant={optimisticFollowing ? "secondary" : "default"}
|
variant={optimisticFollowing ? "secondary" : "default"}
|
||||||
|
className="relative rounded-full"
|
||||||
data-following={optimisticFollowing}
|
data-following={optimisticFollowing}
|
||||||
>
|
>
|
||||||
{optimisticFollowing ? (
|
{optimisticFollowing ? (
|
||||||
@@ -37,5 +106,6 @@ export function FollowButton({ username, isInitiallyFollowing }: FollowButtonPro
|
|||||||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
@@ -10,25 +11,33 @@ export function Header() {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none">
|
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-white/20 bg-background/80 glass-effect glossy-effect bottom rounded-none shadow-fa-md">
|
||||||
<div className="container flex h-14 items-center px-2">
|
<div className="container flex h-14 items-center px-2">
|
||||||
<div className="flex gap-2">
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center gap-1">
|
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
|
||||||
<span className="hidden font-bold text-primary sm:inline-block">
|
<Image
|
||||||
|
src="/icon.avif"
|
||||||
|
alt="Thoughts"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg shadow-fa-sm"
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline-block font-bold text-primary text-shadow-sm">
|
||||||
Thoughts
|
Thoughts
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<MainNav />
|
<MainNav />
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
{token ? (
|
{token ? (
|
||||||
<UserNav />
|
<UserNav />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm" variant="outline" className="rounded-full">
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm" className="rounded-full">
|
||||||
<Link href="/register">Register</Link>
|
<Link href="/register">Register</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user