Compare commits

...

23 Commits

Author SHA1 Message Date
636d3d453d fix: resolve thoughts compile errors after k-ap migration
Some checks failed
lint / lint (push) Failing after 5m0s
test / unit (push) Failing after 4m59s
test / integration (push) Failing after 5m2s
2026-05-17 23:02:49 +02:00
9172c82d54 chore: move ap_ports into activitypub adapter, delete activitypub-base 2026-05-17 22:48:22 +02:00
cd2eb48ddb chore: switch activitypub-base to k-ap git dep 2026-05-17 22:47:32 +02:00
c5d9833c8b refactor: replace long arg lists with input/config structs and builder
Some checks failed
lint / lint (push) Failing after 7m8s
test / unit (push) Successful in 17m2s
test / integration (push) Failing after 17m47s
- Thought::new_local → NewThought struct (7 args → 1)
- UserWriter::update_profile → UpdateProfileInput struct (6 args → 2)
- update_profile use case → UpdateProfileInput (8 args → 3)
- ActivityPubService::new → builder pattern (9 args → 5 required + 4 optional setters)
- accept_note → AcceptNoteInput struct (8 args → 1)
- ThoughtNote::new_public → ThoughtNoteInput struct (8 args → 1)

Remove all #[allow(clippy::too_many_arguments)] annotations.
2026-05-17 12:25:53 +02:00
f39c1a614d clean up
Some checks failed
lint / lint (push) Failing after 7m18s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:15:27 +02:00
30c8a17168 clean up
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
2026-05-17 12:14:45 +02:00
6a8c8b1fb8 chore: add pre-commit fmt+clippy hooks, fix clippy warnings 2026-05-17 12:09:24 +02:00
4ec0725ff8 fmt
Some checks failed
lint / lint (push) Failing after 5m3s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:04:51 +02:00
31e0f2958c fix: make ThoughtNote sensitive field optional (default false) 2026-05-17 12:02:58 +02:00
555121ea75 fix: promote worker event logs from debug to info 2026-05-17 12:02:13 +02:00
9e795eefdc fix: make ThoughtNote url field optional for AP compat
Some checks failed
lint / lint (push) Failing after 5m1s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 11:57:10 +02:00
18cf2c9f54 feat: implement verify() for all stub activity handlers
Undo: inner activity actor must match Undo actor
Announce/Like/Block: verify_domains_match(activity_id, actor_url)
Add: attributedTo must match actor (same as Create/Update)
2026-05-17 11:55:17 +02:00
b58c96b843 feat: implement federation post/connections backfill schedulers
Some checks failed
lint / lint (push) Failing after 5m12s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
schedule_actor_posts_fetch now spawns backfill_outbox in background,
fetching all pages of a remote outbox and persisting via accept_note.
schedule_connections_fetch follows AP collection next-links, resolves
profiles, and caches them in the DB. Both were no-ops ("deferred").

Add connections_repo field to ActivityPubService; wire both factories.
2026-05-17 11:49:53 +02:00
8ea24461ba feat: load more pagination for user profile thoughts 2026-05-16 15:21:18 +02:00
e14a9f90c8 fix: route local users to /users/{username} in remote connection lists 2026-05-16 15:17:58 +02:00
28756ef4cd feat: load more pagination for remote user posts 2026-05-16 15:14:53 +02:00
7f27ae49c3 fix: overflow-y scroll on html to prevent layout shift on dropdown open 2026-05-16 15:12:41 +02:00
59f3423c00 fix: break-all on fediverse handle to prevent overflow 2026-05-16 15:07:30 +02:00
c48aa33592 fix: scrollbar-gutter stable to prevent bg flicker on dropdown open 2026-05-16 15:05:28 +02:00
8f3aa4b891 fix: wrap background image in fixed div so it stays put on scroll 2026-05-16 15:03:41 +02:00
32bfb00970 feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments
Some checks failed
lint / lint (push) Failing after 5m7s
test / unit (push) Successful in 16m24s
test / integration (push) Failing after 18m14s
2026-05-16 14:55:51 +02:00
7ce2901c2a docs: add Frutiger Aero redesign implementation plan 2026-05-16 13:53:44 +02:00
8bbc713093 docs: add Frutiger Aero redesign spec 2026-05-16 13:46:25 +02:00
112 changed files with 2898 additions and 5850 deletions

18
.claude/settings.json Normal file
View 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
View File

@@ -5,22 +5,6 @@ version = 4
[[package]]
name = "activitypub"
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 = [
"activitypub_federation",
"anyhow",
@@ -28,8 +12,8 @@ dependencies = [
"axum",
"chrono",
"domain",
"enum_delegate",
"futures",
"k-ap",
"reqwest",
"serde",
"serde_json",
@@ -289,7 +273,7 @@ dependencies = [
name = "application"
version = "0.1.0"
dependencies = [
"activitypub-base",
"activitypub",
"async-trait",
"chrono",
"domain",
@@ -596,7 +580,6 @@ name = "bootstrap"
version = "0.1.0"
dependencies = [
"activitypub",
"activitypub-base",
"async-nats",
"async-trait",
"auth",
@@ -605,6 +588,7 @@ dependencies = [
"dotenvy",
"event-transport",
"http 1.4.0",
"k-ap",
"nats",
"postgres",
"postgres-federation",
@@ -2005,6 +1989,27 @@ dependencies = [
"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]]
name = "language-tags"
version = "0.3.2"
@@ -2452,7 +2457,7 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
name = "postgres"
version = "0.1.0"
dependencies = [
"activitypub-base",
"activitypub",
"async-trait",
"chrono",
"domain",
@@ -2470,10 +2475,10 @@ dependencies = [
name = "postgres-federation"
version = "0.1.0"
dependencies = [
"activitypub-base",
"anyhow",
"async-trait",
"chrono",
"k-ap",
"sqlx",
"tokio",
"tracing",
@@ -2522,7 +2527,7 @@ dependencies = [
name = "presentation"
version = "0.1.0"
dependencies = [
"activitypub-base",
"activitypub",
"api-types",
"application",
"async-trait",
@@ -4715,7 +4720,6 @@ name = "worker"
version = "0.1.0"
dependencies = [
"activitypub",
"activitypub-base",
"application",
"async-nats",
"domain",
@@ -4723,6 +4727,7 @@ dependencies = [
"event-payload",
"event-transport",
"futures",
"k-ap",
"nats",
"postgres",
"postgres-federation",

View File

@@ -9,7 +9,6 @@ members = [
"crates/adapters/postgres",
"crates/adapters/postgres-search",
"crates/adapters/postgres-federation",
"crates/adapters/activitypub-base",
"crates/adapters/activitypub",
"crates/adapters/auth",
"crates/adapters/nats",
@@ -46,7 +45,6 @@ api-types = { path = "crates/api-types" }
postgres = { path = "crates/adapters/postgres" }
postgres-search = { path = "crates/adapters/postgres-search" }
postgres-federation = { path = "crates/adapters/postgres-federation" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
activitypub = { path = "crates/adapters/activitypub" }
auth = { path = "crates/adapters/auth" }
nats = { path = "crates/adapters/nats" }

View File

@@ -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"

View File

@@ -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),
}

View File

@@ -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)))
}

View File

@@ -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;

View File

@@ -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>;
}

View File

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

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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};

View File

@@ -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;

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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"
);
}

View File

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

View File

@@ -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");
}

View File

@@ -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())
}

View File

@@ -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>;
}

View File

@@ -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())
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }
@@ -14,3 +14,8 @@ chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
reqwest = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }

View File

@@ -7,9 +7,10 @@ use chrono::{DateTime, Utc};
use std::sync::Arc;
use url::Url;
use crate::note::ThoughtNote;
use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls;
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
use k_ap::ApObjectHandler;
use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
@@ -58,16 +59,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
let note = ThoughtNote::new_public(ThoughtNoteInput {
id: note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at,
content: e.thought.content.as_str().to_owned(),
published: e.thought.created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
sensitive: e.thought.sensitive,
summary: e.thought.content_warning,
followers_url: followers,
});
Ok((note_url, serde_json::to_value(&note)?))
})
.collect()
@@ -96,16 +97,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
let note = ThoughtNote::new_public(ThoughtNoteInput {
id: note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
created_at,
content: e.thought.content.as_str().to_owned(),
published: created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
sensitive: e.thought.sensitive,
summary: e.thought.content_warning,
followers_url: followers,
});
Ok((note_url, serde_json::to_value(&note)?, created_at))
})
.collect()
@@ -141,17 +142,18 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct"
};
let thought_id = self.repo
.accept_note(
ap_id.as_str(),
&author_id,
&note.content,
note.published,
note.sensitive,
note.summary,
let thought_id = self
.repo
.accept_note(AcceptNoteInput {
ap_id: ap_id.as_str(),
author_id: &author_id,
content: &note.content,
published: note.published,
sensitive: note.sensitive,
content_warning: note.summary,
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
.map_err(|e| anyhow!("{e}"))?;

View File

@@ -1,7 +1,11 @@
pub mod handler;
pub mod note;
pub mod port;
pub mod service;
pub mod urls;
pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote;
pub use port::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
pub use service::ApFederationAdapter;
pub use urls::ThoughtsUrls;

View File

@@ -1,5 +1,5 @@
use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use k_ap::NoteType;
use k_ap::AS_PUBLIC;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -11,7 +11,8 @@ pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
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 content: String,
pub published: DateTime<Utc>,
@@ -21,6 +22,7 @@ pub struct ThoughtNote {
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
#[serde(default)]
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
@@ -28,30 +30,31 @@ pub struct ThoughtNote {
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 {
#[allow(clippy::too_many_arguments)]
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 {
pub fn new_public(p: ThoughtNoteInput) -> Self {
Self {
kind: Default::default(),
url: id.clone(),
id,
attributed_to: actor_url,
content,
published,
url: Some(p.id.clone()),
id: p.id,
attributed_to: p.actor_url,
content: p.content,
published: p.published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to,
sensitive,
summary,
cc: vec![p.followers_url.to_string()],
in_reply_to: p.in_reply_to,
sensitive: p.sensitive,
summary: p.summary,
tag: Vec::new(),
}
}

View File

@@ -2,16 +2,16 @@ use super::*;
#[test]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(
"https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(),
chrono::Utc::now(),
None,
false,
None,
"https://example.com/users/alice/followers".parse().unwrap(),
);
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
id: "https://example.com/thoughts/1".parse().unwrap(),
actor_url: "https://example.com/users/alice".parse().unwrap(),
content: "Hello world".to_string(),
published: chrono::Utc::now(),
in_reply_to: None,
sensitive: false,
summary: None,
followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
});
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));

View File

@@ -5,6 +5,17 @@ use domain::{
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).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
@@ -44,7 +55,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
-> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
@@ -61,18 +72,8 @@ pub trait ActivityPubRepository: Send + Sync {
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
async fn accept_note(
&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>;
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
@@ -99,7 +100,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>;
-> Result<Option<ActorApUrls>, DomainError>;
}
#[async_trait]

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
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.

View File

@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
Ok(())
}
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> {
Ok(vec![])

View File

@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
}
}
#[cfg(test)]
mod tests;

View File

@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
}
}
#[cfg(test)]
mod tests;

View File

@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
}
}
#[cfg(test)]
mod tests;

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -3,7 +3,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{
use k_ap::{
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
FollowingStatus, RemoteActor,
};

View File

@@ -1,7 +1,7 @@
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserWriter},
@@ -19,15 +19,15 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local(content).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(u, t)
}

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
domain = { workspace = true }
activitypub-base = { workspace = true }
activitypub = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }

View File

@@ -6,7 +6,7 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use activitypub::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
@@ -210,17 +210,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ())
}
async fn accept_note(
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: DateTime<Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError> {
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
let AcceptNoteInput {
ap_id,
author_id,
content,
published,
sensitive,
content_warning,
visibility,
in_reply_to,
} = input;
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => {
@@ -254,12 +254,11 @@ impl ActivityPubRepository for PgActivityPubRepository {
.into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) =
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id)
.fetch_one(&self.pool)
.await
.into_domain()?;
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(ThoughtId::from_uuid(row.0))
}

View File

@@ -1,68 +1,68 @@
use super::*;
use activitypub_base::ActivityPubRepository;
use super::*;
use activitypub::{AcceptNoteInput, ActivityPubRepository};
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(
ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
false,
None,
"public",
None,
)
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(AcceptNoteInput {
ap_id,
author_id: &author,
content: "hello from remote",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
})
.await
.unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice")
.await
.unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
let thought_id = repo
.accept_note(AcceptNoteInput {
ap_id: "https://remote.example/notes/1",
author_id: &actor_user_id,
content: "Hello #rust world",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
})
.await
.unwrap();
#[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice")
.await
.unwrap();
let thought_id = repo
.accept_note(
"https://remote.example/notes/1",
&actor_user_id,
"Hello #rust world",
chrono::Utc::now(),
false,
None,
"public",
None,
)
.await
.unwrap();
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind("https://remote.example/notes/1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(thought_id.as_uuid(), row.0);
}
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind("https://remote.example/notes/1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(thought_id.as_uuid(), row.0);
}

View File

@@ -1,49 +1,49 @@
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "abc123".into(),
name: "test".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test");
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "abc123".into(),
name: "test".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
}

View File

@@ -1,34 +1,34 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
}

View File

@@ -1,35 +1,35 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}

View File

@@ -1,69 +1,75 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{Thought, Visibility},
user::User,
},
ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*,
};
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local(content).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::public(
PageParams {
page: 1,
per_page: 20,
},
None,
Visibility::Public,
))
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::search(
"hello world",
PageParams {
page: 1,
per_page: 20,
},
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 },
None,
))
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::search(
"hello world",
PageParams { page: 1, per_page: 20 },
None,
))
.await
.unwrap();
assert!(result.total >= 1);
assert!(result
.items
.iter()
.any(|e| e.thought.content.as_str() == "hello world"));
}
))
.await
.unwrap();
assert!(result.total >= 1);
assert!(result
.items
.iter()
.any(|e| e.thought.content.as_str() == "hello world"));
}

View File

@@ -1,58 +1,58 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_follow(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_follow(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")]
async fn update_state(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Pending,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
.await
.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")]
async fn update_state(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Pending,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
.await
.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]);
}

View File

@@ -1,15 +1,15 @@
pub mod activitypub;
pub mod engagement;
pub mod api_key;
pub mod block;
pub mod boost;
mod db_error;
pub mod engagement;
pub mod failed_event;
pub mod outbox;
pub mod feed;
pub mod follow;
pub mod like;
pub mod notification;
pub mod outbox;
pub mod remote_actor;
pub mod remote_actor_connections;
pub mod tag;

View File

@@ -1,35 +1,35 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")]
async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool);
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool);
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool);
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool);
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}

View File

@@ -1,67 +1,67 @@
use super::*;
use crate::test_helpers;
use chrono::Utc;
use domain::{
models::{notification::NotificationKind, user::User},
value_objects::*,
use super::*;
use crate::test_helpers;
use chrono::Utc;
use domain::{
models::{notification::NotificationKind, user::User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) {
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 repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams;
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
},
read: false,
created_at: Utc::now(),
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) {
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 repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams;
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
repo.save(&n).await.unwrap();
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap();
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert_eq!(page.total, 1);
assert!(!page.items[0].read);
}
)
.await
.unwrap();
assert_eq!(page.total, 1);
assert!(!page.items[0].read);
}
#[sqlx::test(migrations = "./migrations")]
async fn mark_all_read(pool: sqlx::PgPool) {
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 repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams;
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
#[sqlx::test(migrations = "./migrations")]
async fn mark_all_read(pool: sqlx::PgPool) {
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 repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams;
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
},
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap();
repo.mark_all_read(&user.id).await.unwrap();
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap();
repo.mark_all_read(&user.id).await.unwrap();
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(page.items[0].read);
}
)
.await
.unwrap();
assert!(page.items[0].read);
}

View File

@@ -1,48 +1,48 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};
use domain::{
models::{
thought::{NewThought, Thought, Visibility},
user::User,
},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn find_or_create_tag(pool: sqlx::PgPool) {
let repo = PgTagRepository::new(pool);
let t1 = 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.name, "rust");
}
#[sqlx::test(migrations = "./migrations")]
async fn find_or_create_tag(pool: sqlx::PgPool) {
let repo = PgTagRepository::new(pool);
let t1 = 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.name, "rust");
}
#[sqlx::test(migrations = "./migrations")]
async fn attach_and_list(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap();
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
let tags = repo.list_for_thought(&t.id).await.unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "greetings");
}
#[sqlx::test(migrations = "./migrations")]
async fn attach_and_list(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local("hi").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap();
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
let tags = repo.list_for_thought(&t.id).await.unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "greetings");
}

View File

@@ -1,7 +1,7 @@
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
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) {
let user = seed_user(pool, "alice", "alice@ex.com").await;
let trepo = PgThoughtRepository::new(pool.clone());
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("hi").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(user, t)
}

View File

@@ -1,90 +1,90 @@
use super::*;
use crate::test_helpers::seed_user;
use domain::{
models::thought::{Thought, Visibility},
value_objects::*,
};
use super::*;
use crate::test_helpers::seed_user;
use domain::{
models::thought::{NewThought, Thought, Visibility},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("hello world").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world");
assert!(found.local);
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("hello world").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world");
assert!(found.local);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("bye").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("bye").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("secret").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("secret").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("root").unwrap(),
None,
Visibility::Public,
None,
false,
);
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("reply").unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("root").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
let reply = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("reply").unwrap(),
in_reply_to_id: Some(root.id.clone()),
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2);
}

View File

@@ -1,47 +1,47 @@
use super::*;
use crate::user::PgUserRepository;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
use super::*;
use crate::user::PgUserRepository;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].0.position, 1);
assert_eq!(friends[0].1.username.as_str(), "bob");
}
#[sqlx::test(migrations = "./migrations")]
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].0.position, 1);
assert_eq!(friends[0].1.username.as_str(), "bob");
}
#[sqlx::test(migrations = "./migrations")]
async fn replace_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol");
}
#[sqlx::test(migrations = "./migrations")]
async fn replace_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol");
}

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::feed::{PageParams, Paginated, UserSummary},
models::user::User,
models::user::{UpdateProfileInput, User},
ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username},
};
@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
.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)]
struct Row {
id: uuid::Uuid,
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
following_count: r.following_count,
})
.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> {
@@ -195,18 +203,19 @@ impl UserReader for PgUserRepository {
return Ok(HashMap::new());
}
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
let rows = sqlx::query_as::<_, UserRow>(
&format!("{USER_SELECT} WHERE id = ANY($1)")
)
.bind(&uuids[..])
.fetch_all(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
.bind(&uuids[..])
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows.into_iter().map(|r| {
let user = User::from(r);
(user.id.clone(), user)
}).collect())
Ok(rows
.into_iter()
.map(|r| {
let user = User::from(r);
(user.id.clone(), user)
})
.collect())
}
}
@@ -256,21 +265,17 @@ impl UserWriter for PgUserRepository {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
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"
)
.bind(user_id.as_uuid())
.bind(display_name)
.bind(bio)
.bind(avatar_url)
.bind(header_url)
.bind(custom_css)
.bind(input.display_name)
.bind(input.bio)
.bind(input.avatar_url)
.bind(input.header_url)
.bind(input.custom_css)
.execute(&self.pool)
.await
.into_domain()

View File

@@ -1,69 +1,72 @@
use super::*;
use domain::{models::user::User, value_objects::*};
use super::*;
use domain::{
models::user::{UpdateProfileInput, User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com");
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com");
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let result = repo
.find_by_username(&Username::new("ghost").unwrap())
.await
.unwrap();
assert!(result.is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("bob").unwrap(),
Email::new("bob@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo
.find_by_email(&Email::new("bob@ex.com").unwrap())
.await
.unwrap();
assert!(found.is_some());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("charlie").unwrap(),
Email::new("charlie@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
repo.update_profile(
&user.id,
Some("Charlie".into()),
Some("bio".into()),
None,
None,
None,
)
#[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let result = repo
.find_by_username(&Username::new("ghost").unwrap())
.await
.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio"));
}
assert!(result.is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("bob").unwrap(),
Email::new("bob@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo
.find_by_email(&Email::new("bob@ex.com").unwrap())
.await
.unwrap();
assert!(found.is_some());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("charlie").unwrap(),
Email::new("charlie@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
repo.update_profile(
&user.id,
UpdateProfileInput {
display_name: Some("Charlie".into()),
bio: Some("bio".into()),
..Default::default()
},
)
.await
.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio"));
}

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
domain = { workspace = true }
activitypub-base = { workspace = true }
activitypub = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,4 +1,4 @@
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use activitypub::{ActivityPubRepository, OutboundFederationPort};
use domain::{
errors::DomainError,
events::DomainEvent,

View File

@@ -1,11 +1,11 @@
use super::*;
use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use crate::testing::TestApRepo;
use activitypub::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::thought::{Thought, Visibility},
models::thought::{NewThought, Thought, Visibility},
models::user::User,
testing::TestStore,
value_objects::*,
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
self.announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_undo_announce(
&self,
_: &UserId,
ap_id: &str,
) -> Result<(), DomainError> {
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
@@ -101,15 +92,15 @@ fn alice() -> User {
}
fn local_thought(author_id: UserId) -> Thought {
Thought::new_local(
ThoughtId::new(),
author_id,
Content::new_local("hello").unwrap(),
None,
Visibility::Public,
None,
false,
)
Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: author_id,
content: Content::new_local("hello").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
})
}
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 {
thoughts: 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() {
let store = TestStore::default();
let alice = alice();
let thought = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("private").unwrap(),
None,
Visibility::Direct,
None,
false,
);
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("private").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Direct,
content_warning: None,
sensitive: false,
});
store.users.lock().unwrap().push(alice.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() {
let store = TestStore::default();
let alice = alice();
let thought = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("for followers").unwrap(),
None,
Visibility::Followers,
None,
false,
);
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("for followers").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Followers,
content_warning: None,
sensitive: false,
});
store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone());

View File

@@ -2,7 +2,7 @@ use super::*;
use domain::{
models::{
notification::NotificationKind,
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
testing::TestStore,
@@ -24,15 +24,15 @@ async fn like_creates_notification_for_thought_author() {
let store = TestStore::default();
let alice = alice();
let bob_id = UserId::new();
let thought = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("hello").unwrap(),
None,
Visibility::Public,
None,
false,
);
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("hello").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
thoughts: Arc::new(store.clone()),
@@ -54,15 +54,15 @@ async fn like_creates_notification_for_thought_author() {
async fn self_like_creates_no_notification() {
let store = TestStore::default();
let alice = alice();
let thought = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("hello").unwrap(),
None,
Visibility::Public,
None,
false,
);
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("hello").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
thoughts: Arc::new(store.clone()),
@@ -103,15 +103,15 @@ async fn reply_creates_notification_for_original_author() {
let store = TestStore::default();
let alice = alice();
let bob_id = UserId::new();
let original = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("original").unwrap(),
None,
Visibility::Public,
None,
false,
);
let original = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("original").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService {
thoughts: Arc::new(store.clone()),
@@ -133,15 +133,15 @@ async fn reply_creates_notification_for_original_author() {
async fn self_reply_creates_no_notification() {
let store = TestStore::default();
let alice = alice();
let original = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("original").unwrap(),
None,
Visibility::Public,
None,
false,
);
let original = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("original").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService {
thoughts: Arc::new(store.clone()),
@@ -161,15 +161,15 @@ async fn self_reply_creates_no_notification() {
async fn self_boost_creates_no_notification() {
let store = TestStore::default();
let alice = alice();
let thought = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("hello").unwrap(),
None,
Visibility::Public,
None,
false,
);
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("hello").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
thoughts: Arc::new(store.clone()),

View File

@@ -1,5 +1,5 @@
/// Test helpers for application-layer tests that need activitypub_base traits.
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
/// Test helpers for application-layer tests that need activitypub traits.
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use async_trait::async_trait;
use domain::{
errors::DomainError,
@@ -95,22 +95,11 @@ impl ActivityPubRepository for TestApRepo {
}
async fn accept_note(
&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>,
_input: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
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> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -34,21 +34,16 @@ pub async fn register(
}
let hash = hasher.hash(&input.password).await?;
let user = User::new_local(UserId::new(), username, email, hash);
users
.save(&user)
.await
.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into())
}
DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into())
}
DomainError::UniqueViolation { .. } => {
DomainError::Conflict("already exists".into())
}
other => other,
})?;
users.save(&user).await.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into())
}
DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into())
}
DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
other => other,
})?;
events
.publish(&DomainEvent::UserRegistered {
user_id: user.id.clone(),

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
models::{
feed::{PageParams, Paginated, UserSummary},
user::{UpdateProfileInput, User},
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore},
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> {
self.0.find_by_id(id).await
}
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await
}
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> {
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
}
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
}
}
@@ -50,15 +56,9 @@ impl UserWriter for ConflictOnSaveStore {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.await
self.0.update_profile(user_id, input).await
}
}
@@ -67,10 +67,7 @@ impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await
}
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await
}
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> {
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
}
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
}
}
@@ -98,15 +101,9 @@ impl UserWriter for EmailConflictOnSaveStore {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.await
self.0.update_profile(user_id, input).await
}
}

View File

@@ -1,4 +1,4 @@
use activitypub_base::ActivityPubRepository;
use activitypub::ActivityPubRepository;
use domain::{
errors::DomainError,
models::{
@@ -7,9 +7,9 @@ use domain::{
remote_actor::RemoteActor,
},
ports::{
EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader,
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
RemoteActorConnectionRepository, UserReader,
},
value_objects::UserId,
};
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id,
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 {
let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

@@ -13,5 +13,6 @@ pub async fn get_home_feed(
) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
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
}

View File

@@ -3,7 +3,10 @@ const MAX_TOP_FRIENDS: usize = 8;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{top_friend::TopFriend, user::User},
models::{
top_friend::TopFriend,
user::{UpdateProfileInput, User},
},
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
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(
users: &dyn UserWriter,
events: &dyn EventPublisher,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
users
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await?;
users.update_profile(user_id, input).await?;
events
.publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(),

View File

@@ -1,7 +1,7 @@
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
testing::TestStore,
@@ -22,15 +22,19 @@ async fn like_and_unlike() {
let store = TestStore::default();
let alice = user("alice");
let tid = ThoughtId::new();
store.thoughts.lock().unwrap().push(Thought::new_local(
tid.clone(),
alice.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
));
store
.thoughts
.lock()
.unwrap()
.push(Thought::new_local(NewThought {
id: tid.clone(),
user_id: alice.id.clone(),
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();
assert_eq!(store.likes.lock().unwrap().len(), 1);
unlike_thought(&store, &store, &alice.id, &tid)

View File

@@ -3,9 +3,12 @@ use domain::{
events::DomainEvent,
models::{
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},
};
@@ -43,15 +46,15 @@ pub async fn create_thought(
Some("direct") => Visibility::Direct,
_ => Visibility::Public,
};
let thought = Thought::new_local(
ThoughtId::new(),
input.user_id,
content.clone(),
input.in_reply_to_id.clone(),
let thought = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: input.user_id,
content: content.clone(),
in_reply_to_id: input.in_reply_to_id.clone(),
visibility,
input.content_warning,
input.sensitive,
);
content_warning: input.content_warning,
sensitive: input.sensitive,
});
thoughts.save(&thought).await?;
// Extract and attach hashtags from content.
@@ -132,11 +135,23 @@ pub async fn get_thought_view(
.find_by_id(&thought.user_id)
.await?
.ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
let mut map = engagement
.get_for_thoughts(std::slice::from_ref(id), viewer)
.await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or((
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.
@@ -169,10 +184,20 @@ pub async fn get_thread_views(
.get(&thought.user_id)
.cloned()
.ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
}
Ok(entries)
}

View File

@@ -31,9 +31,16 @@ async fn create_thought_saves_and_stages_outbox_event() {
let outbox = TestOutbox::default();
let u = user();
store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
.await
.unwrap();
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&outbox,
input(u.id.clone()),
)
.await
.unwrap();
assert_eq!(out.thought.content.as_str(), "hello");
let staged = outbox.staged();
assert_eq!(staged.len(), 1);
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
let staged = outbox.staged();
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]
@@ -82,9 +91,15 @@ async fn delete_own_thought_succeeds() {
)
.await
.unwrap();
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id)
.await
.unwrap();
delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&u.id,
)
.await
.unwrap();
assert!(store.thoughts.lock().unwrap().is_empty());
}
@@ -113,9 +128,15 @@ async fn delete_other_thought_returns_not_found() {
)
.await
.unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id)
.await
.unwrap_err();
let err = delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&bob.id,
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
@@ -124,9 +145,16 @@ async fn edit_thought_changes_content_and_emits_event() {
let store = TestStore::default();
let alice = user();
store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone()))
.await
.unwrap();
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(alice.id.clone()),
)
.await
.unwrap();
let tid = out.thought.id.clone();
edit_thought(&store, &store, &tid, &alice.id, "updated".to_string())
@@ -194,7 +222,7 @@ async fn create_reply_sets_in_reply_to_id() {
// 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};
fn make_user() -> User {
@@ -207,24 +235,28 @@ fn make_user() -> User {
}
fn make_thought(user_id: UserId) -> Thought {
Thought::new_local(
ThoughtId::new(),
Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id,
Content::new_local(String::from("hello")).unwrap(),
None,
Visibility::Public,
None,
false,
)
content: Content::new_local(String::from("hello")).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
})
}
#[tokio::test]
async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default();
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());
<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)
.await
@@ -248,19 +280,25 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
async fn get_thread_views_batches_correctly() {
let store = TestStore::default();
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());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local(String::from("reply")).unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &root)
.await
.unwrap();
let reply = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local(String::from("reply")).unwrap(),
in_reply_to_id: Some(root.id.clone()),
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
<TestStore as ThoughtRepository>::save(&store, &reply)
.await
.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await

View File

@@ -14,7 +14,7 @@ postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { 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 }
event-transport = { workspace = true }
auth = { workspace = true }

View File

@@ -5,10 +5,14 @@ use async_trait::async_trait;
use sqlx::PgPool;
use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::service::ActivityPubService;
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use k_ap::ActivityPubService;
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 nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository;
@@ -23,7 +27,7 @@ use crate::config::Config;
/// Everything the binary needs to start serving.
pub struct Infrastructure {
pub state: AppState,
pub ap_service: Arc<ActivityPubService>,
pub ap_service: Arc<ApFederationAdapter>,
}
struct NoOpEventPublisher;
@@ -68,8 +72,10 @@ pub async fn build(cfg: &Config) -> Infrastructure {
};
// 3. ActivityPub federation
let ap_service = Arc::new(
ActivityPubService::new(
let connections_repo =
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let raw_ap_service = Arc::new(
ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(
pool.clone(),
@@ -82,14 +88,15 @@ pub async fn build(cfg: &Config) -> Infrastructure {
Arc::new(postgres::tag::PgTagRepository::new(pool.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
.expect("Failed to build ActivityPubService"),
);
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
// 4. Application state
let state = AppState {
@@ -129,9 +136,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
api_key_auth: Arc::new(ApiKeyServiceImpl::new(
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
)),
api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
postgres::api_key::PgApiKeyRepository::new(pool.clone()),
))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
};

View File

@@ -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 {
pub fn new_local(
id: ThoughtId,
user_id: UserId,
content: Content,
in_reply_to_id: Option<ThoughtId>,
visibility: Visibility,
content_warning: Option<String>,
sensitive: bool,
) -> Self {
pub fn new_local(p: NewThought) -> Self {
Self {
id,
user_id,
content,
in_reply_to_id,
visibility,
content_warning,
sensitive,
id: p.id,
user_id: p.user_id,
content: p.content,
in_reply_to_id: p.in_reply_to_id,
visibility: p.visibility,
content_warning: p.content_warning,
sensitive: p.sensitive,
local: true,
created_at: Utc::now(),
updated_at: None,

View File

@@ -1,6 +1,15 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username};
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)]
pub struct User {
pub id: UserId,

View File

@@ -12,7 +12,7 @@ use crate::{
tag::Tag,
thought::Thought,
top_friend::TopFriend,
user::User,
user::{UpdateProfileInput, User},
},
value_objects::{
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 list_with_stats(&self) -> Result<Vec<UserSummary>, 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>;
}
@@ -68,11 +69,7 @@ pub trait UserWriter: Send + Sync {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError>;
}
@@ -353,19 +350,43 @@ pub struct FeedQuery {
impl FeedQuery {
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 {
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 {
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 {
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 {
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>;
}
#[async_trait]
pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch(

View File

@@ -10,7 +10,7 @@ use crate::{
tag::Tag,
thought::Thought,
top_friend::TopFriend,
user::User,
user::{UpdateProfileInput, User},
},
ports::*,
value_objects::{
@@ -83,17 +83,30 @@ impl UserReader for TestStore {
.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 total = all.len() as i64;
let start = page.offset() as usize;
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
let items: Vec<UserSummary> = all
.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> {
let g = self.users.lock().unwrap();
let map = g.iter()
let map = g
.iter()
.filter(|u| ids.contains(&u.id))
.map(|u| (u.id.clone(), u.clone()))
.collect();
@@ -112,11 +125,7 @@ impl UserWriter for TestStore {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
if let Some(u) = self
.users
@@ -125,11 +134,11 @@ impl UserWriter for TestStore {
.iter_mut()
.find(|u| &u.id == user_id)
{
u.display_name = display_name;
u.bio = bio;
u.avatar_url = avatar_url;
u.header_url = header_url;
u.custom_css = custom_css;
u.display_name = input.display_name;
u.bio = input.bio;
u.avatar_url = input.avatar_url;
u.header_url = input.header_url;
u.custom_css = input.custom_css;
}
Ok(())
}
@@ -294,7 +303,16 @@ impl EngagementRepository for TestStore {
&self,
thought_ids: &[ThoughtId],
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};
let likes = self.likes.lock().unwrap();
let boosts = self.boosts.lock().unwrap();
@@ -304,12 +322,29 @@ impl EngagementRepository for TestStore {
for tid in thought_ids {
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 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 {
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid),
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid),
liked: likes
.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)
}
@@ -763,7 +798,10 @@ impl RemoteActorConnectionRepository for TestStore {
#[async_trait]
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 {
items: vec![],
total: 0,

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
domain = { workspace = true }
activitypub-base = { workspace = true }
activitypub = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }
axum = { workspace = true }

View File

@@ -8,11 +8,7 @@ use api_types::{
responses::{ApiKeyResponse, CreatedApiKeyResponse},
};
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
use uuid::Uuid;

View File

@@ -1,8 +1,4 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::Deps,
};
use crate::{deps_struct, errors::ApiError, extractors::Deps};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse},

View File

@@ -4,6 +4,7 @@ use crate::{
handlers::feed::to_thought_response,
state::AppState,
};
use activitypub::ActivityPubRepository;
use api_types::{
requests::PaginationQuery,
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
@@ -15,7 +16,6 @@ use axum::{
extract::{Path, Query},
Json,
};
use activitypub_base::ActivityPubRepository;
use domain::{
models::feed::PageParams,
ports::{

View File

@@ -16,7 +16,10 @@ use axum::{
};
use domain::{
models::feed::PageParams,
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
TagRepository, UserRepository,
},
};
deps_struct!(FeedDeps {
@@ -224,7 +227,10 @@ pub async fn user_thoughts_handler(
page: q.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!({
"total": result.total,
"page": result.page,
@@ -241,7 +247,10 @@ pub async fn get_popular_tags(
.get("limit")
.and_then(|v| v.parse().ok())
.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!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name,
@@ -268,7 +277,10 @@ pub async fn tag_thoughts_handler(
page: q.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!({
"tag": tag_name,
"total": result.total,

View File

@@ -8,11 +8,7 @@ use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
};

View File

@@ -1,3 +1,4 @@
use crate::handlers::auth::to_user_response;
use crate::{
deps_struct,
errors::ApiError,
@@ -5,14 +6,9 @@ use crate::{
};
use api_types::requests::SetTopFriendsRequest;
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::social::*;
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{
ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,

View File

@@ -9,18 +9,16 @@ use api_types::{
responses::ErrorResponse,
};
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,
};
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
Json,
};
use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
use domain::{
models::feed::{EngagementStats, FeedEntry, ViewerContext},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserRepository,
},
value_objects::ThoughtId,
};
use uuid::Uuid;
@@ -74,8 +72,15 @@ pub async fn post_thought(
let entry = FeedEntry {
thought: out.thought,
author,
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 },
viewer: Some(ViewerContext { liked: false, boosted: false }),
stats: EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
viewer: Some(ViewerContext {
liked: false,
boosted: false,
}),
};
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
}
@@ -101,7 +106,9 @@ pub async fn get_thought_handler(
viewer.as_ref(),
)
.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(
@@ -119,7 +126,14 @@ pub async fn delete_thought_handler(
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> 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)
}

View File

@@ -17,8 +17,9 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use domain::ports::{
EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository,
use domain::{
models::user::UpdateProfileInput,
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
};
use std::sync::Arc;
@@ -96,11 +97,13 @@ pub async fn patch_profile(
&*d.users,
&*d.events,
&uid,
body.display_name,
body.bio,
body.avatar_url,
body.header_url,
body.custom_css,
UpdateProfileInput {
display_name: body.display_name,
bio: body.bio,
avatar_url: body.avatar_url,
header_url: body.header_url,
custom_css: body.custom_css,
},
)
.await?;
let user = fetch_user(&*d.users, &uid).await?;
@@ -191,9 +194,7 @@ pub async fn get_users(
})))
}
pub async fn get_user_count(
Deps(d): Deps<UsersDeps>,
) -> Result<Json<serde_json::Value>, ApiError> {
pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> {
let count = d.users.count().await?;
Ok(Json(serde_json::json!({ "count": count })))
}

View File

@@ -1,4 +1,4 @@
use activitypub_base::ActivityPubRepository;
use activitypub::ActivityPubRepository;
use domain::ports::*;
use std::sync::Arc;

View File

@@ -1,5 +1,5 @@
use crate::state::AppState;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use async_trait::async_trait;
use domain::{
errors::DomainError,
@@ -68,22 +68,11 @@ impl ActivityPubRepository for NoOpApRepo {
}
async fn accept_note(
&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>,
_input: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
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> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -13,7 +13,7 @@ application = { workspace = true }
nats = { workspace = true }
event-transport = { 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 }
postgres = { workspace = true }
postgres-federation = { workspace = true }

View File

@@ -1,11 +1,12 @@
use postgres::failed_event::PgFailedEventStore;
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
use sqlx::PgPool;
use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::ActivityPubService;
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use activitypub::{ActivityPubRepository, OutboundFederationPort};
use k_ap::ActivityPubService;
use application::services::{FederationEventService, NotificationEventService};
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use domain::ports::EventPublisher;
use postgres::activitypub::PgActivityPubRepository;
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)
let ap_service = Arc::new(
ActivityPubService::new(
let connections_repo_worker =
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let raw_ap_service = Arc::new(
ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(
pool.clone(),
@@ -51,15 +54,14 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
None,
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
)),
base_url.to_string(),
false,
"thoughts".to_string(),
false,
None,
base_url,
)
.software_name("thoughts")
.build()
.await
.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_repo_worker =
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;

View File

@@ -43,13 +43,19 @@ async fn main() {
match result {
Ok(envelope) => {
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 f = infra.handlers.federation.handle(event).await;
if n.is_ok() && f.is_ok() {
(envelope.ack)();
tracing::info!(event_type, "event handled ok");
} else {
if let Err(e) = &n {
tracing::error!("notification handler: {e}");

View File

@@ -57,7 +57,11 @@ impl OutboxRelay {
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
Ok(p) => p,
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.
sqlx::query(
"UPDATE outbox_events \
@@ -75,7 +79,10 @@ impl OutboxRelay {
let domain_event = match DomainEvent::try_from(payload) {
Ok(ev) => ev,
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(
"UPDATE outbox_events \
SET delivered = true, delivered_at = now() \
@@ -100,7 +107,11 @@ impl OutboxRelay {
.execute(&mut *tx)
.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) => {
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");

View File

@@ -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.

View File

@@ -48,10 +48,10 @@
/* Frutiger Aero Gradients */
--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-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
hsl(var(--card)) 100%;
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%;
--gradient-fa-card:
180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%;
--gradient-fa-gloss:
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-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 {
html {
overflow-y: scroll;
}
* {
@apply border-border outline-ring/50;
}
body {
@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 {
@@ -312,3 +311,165 @@
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);
}
}

View File

@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header";
import localFont from "next/font/local";
import Image from "next/image";
import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = {
@@ -52,6 +53,16 @@ export default function RootLayout({
return (
<html lang="en">
<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>
<Header />
<main className="flex-1">{children}</main>

View File

@@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count";
import { PaginationNav } from "@/components/pagination-nav";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
import {
ProfileSkeleton,
TagsSkeleton,
CountSkeleton,
} from "@/components/loading-skeleton";
export const metadata: Metadata = {
title: "Home",
@@ -86,9 +90,7 @@ async function FeedPage({
</header>
<ThoughtForm />
<div className="block lg:hidden space-y-6">
{sidebar}
</div>
<div className="block lg:hidden space-y-6">{sidebar}</div>
<div className="space-y-6">
{thoughtThreads.map((thought) => (
@@ -99,7 +101,13 @@ async function FeedPage({
/>
))}
{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>
<PaginationNav
@@ -110,9 +118,7 @@ async function FeedPage({
</main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
{sidebar}
</div>
<div className="sticky top-20 space-y-6">{sidebar}</div>
</aside>
</div>
</div>
@@ -121,28 +127,112 @@ async function FeedPage({
function LandingPage() {
return (
<>
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
<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">
<h1
className="text-5xl font-bold"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Ambient orbs */}
<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
className="text-5xl font-bold relative"
style={{
textShadow:
"0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)",
}}
>
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-3 relative">
A federated social network for short-form thoughts.
<br />
Connect with the Fediverse.
</p>
<div className="mt-8 flex justify-center gap-4 relative">
<Button asChild className="px-7">
<Link href="/login">Login</Link>
</Button>
<Button asChild variant="secondary" className="px-7">
<Link href="/register">Register</Link>
</Button>
</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)",
}}
>
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-2">
Throwback to the golden age of microblogging.
</p>
<div className="mt-8 flex justify-center gap-4">
<Button asChild>
<Link href="/login">Login</Link>
</Button>
<Button variant="secondary" asChild>
<Link href="/register">Register</Link>
</Button>
</div>
<span
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
style={{ boxShadow: "0 0 4px #34d399" }}
/>
Works with Mastodon, Pixelfed &amp; more
</span>
</div>
</div>
</>
</div>
);
}

View File

@@ -65,8 +65,11 @@ export default async function RemoteActorPage({
}
const actor = actorResult.value;
const posts =
postsResult.status === "fulfilled" ? postsResult.value.items : [];
const postsData = postsResult.status === "fulfilled" ? postsResult.value : null;
const posts = postsData?.items ?? [];
const totalPages = postsData
? Math.ceil(postsData.total / postsData.per_page)
: 1;
const me =
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
const following =
@@ -77,7 +80,9 @@ export default async function RemoteActorPage({
<RemoteUserProfile
key={actor.url}
actor={actor}
handle={handle}
initialPosts={posts}
initialTotalPages={totalPages}
me={me}
initialFollowed={initialFollowed}
/>

View File

@@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<RemoteUserCard actor={remoteActor} />
</div>
) : (
<EmptyState message={`No user found at ${query}`} />
<EmptyState emoji="🔍" title="No results" message={`No user found at ${query}`} />
)
) : results ? (
<Tabs defaultValue="thoughts" className="w-full">
@@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</TabsContent>
</Tabs>
) : (
<EmptyState message="No results found or an error occurred." />
<EmptyState emoji="🔍" title="No results" message="No results found or an error occurred." />
)}
</main>
</div>

View File

@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
/>
))}
{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>
</div>

View File

@@ -53,8 +53,7 @@ import { FollowButton } from "@/components/follow-button";
import { TopFriends } from "@/components/top-friends";
import { Suspense } from "react";
import { ProfileSkeleton } from "@/components/loading-skeleton";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { UserThoughtsList } from "@/components/user-thoughts-list";
import { Button } from "@/components/ui/button";
import Link from "next/link";
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 me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const thoughts =
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
const thoughtThreads = buildThoughtThreads(thoughts);
const thoughtsData = thoughtsResult.status === "fulfilled" ? thoughtsResult.value : null;
const thoughts = thoughtsData?.items ?? [];
const totalPages = thoughtsData
? Math.ceil(thoughtsData.total / thoughtsData.per_page)
: 1;
const localFollowersCount =
followersResult.status === "fulfilled"
@@ -194,7 +195,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
@{user.username}
</p>
{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}
</p>
)}
@@ -262,16 +263,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
)}
</TabsList>
<TabsContent value="thoughts" className="space-y-4">
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
currentUser={me}
/>
))}
{thoughtThreads.length === 0 && (
<EmptyState message="This user hasn't posted any public thoughts yet." />
)}
<UserThoughtsList
username={username}
initialThoughts={thoughts}
totalPages={totalPages}
me={me}
/>
</TabsContent>
{isOwnProfile && (
<TabsContent value="federation">

View File

@@ -1,12 +1,39 @@
import Link from "next/link";
interface EmptyStateProps {
message: string
className?: string
emoji?: 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 (
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
{message}
</p>
)
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
<span className="text-4xl animate-float-bob select-none" aria-hidden="true">
{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>
);
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useOptimistic } from "react"
import { useOptimistic, useRef } from "react"
import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
@@ -11,31 +11,101 @@ interface FollowButtonProps {
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) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
const canvasRef = useRef<HTMLCanvasElement>(null)
async function handleClick() {
const next = !optimisticFollowing
setOptimisticFollowing(next)
if (next && canvasRef.current) {
burstParticles(canvasRef.current)
}
try {
await (next ? followUser(username) : unfollowUser(username))
} catch {
setOptimisticFollowing(!next) // revert
setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
}
}
return (
<Button
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
<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
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
className="relative rounded-full"
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./ui/button";
import { UserNav } from "./user-nav";
@@ -10,25 +11,33 @@ export function Header() {
const { token } = useAuth();
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="flex gap-2">
<Link href="/" className="flex items-center gap-1">
<span className="hidden font-bold text-primary sm:inline-block">
Thoughts
</span>
</Link>
<MainNav />
</div>
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<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
</span>
</Link>
<MainNav />
<div className="flex flex-1 items-center justify-end space-x-2">
{token ? (
<UserNav />
) : (
<>
<Button asChild size="sm">
<Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link>
</Button>
<Button asChild size="sm">
<Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link>
</Button>
</>

Some files were not shown because too many files have changed in this diff Show More