feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-05-16 09:42:40 +00:00
parent 071809bc3f
commit 9aee4ceb6d
224 changed files with 35418 additions and 1469 deletions

View File

@@ -0,0 +1,22 @@
[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

@@ -0,0 +1,851 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,372 @@
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

@@ -0,0 +1,167 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::thought::Thought,
value_objects::{ThoughtId, UserId, Username},
};
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
pub ap_id: String,
pub inbox_url: String,
}
/// A local thought ready for AP serialization, with the author's username
/// pre-joined so the handler can build AP URLs without a second query.
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: Thought,
pub author_username: Username,
}
#[async_trait]
pub trait ActivityPubRepository: Send + Sync {
// ── Outbox (local → remote) ──────────────────────────────────────
/// All public local thoughts for this actor. Used for outbox totals
/// and full-collection delivery.
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
/// Cursor page of public local thoughts, newest-first, before `before`.
/// Used for OrderedCollectionPage responses.
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
// ── Remote actor resolution ──────────────────────────────────────
/// 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>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
/// Update display_name and avatar_url for an already-interned remote actor.
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError>;
// ── 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>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
/// Remove a specific remote Note (Delete activity). Only touches
/// remotely-originated thoughts.
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// ── Node-level stats ─────────────────────────────────────────────
/// Total locally-authored thought count for NodeInfo responses.
async fn count_local_notes(&self) -> Result<u64, DomainError>;
/// Return the ActivityPub object URL for a thought, if one is stored.
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
/// 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>;
}
#[async_trait]
pub trait OutboundFederationPort: Send + Sync {
/// Fan out a new local Note to all accepted followers.
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out a Delete tombstone for a now-deleted local Note.
/// `thought_ap_id` is pre-constructed by the caller because the thought
/// has already been deleted from the DB when this fires.
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Note) for an edited local thought.
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out an Announce(object_ap_id) for a boost.
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Undo(Announce) to followers when a boost is removed.
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Actor) to all accepted followers after a profile change.
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,68 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,105 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,82 @@
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

@@ -0,0 +1,138 @@
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

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,75 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,27 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,16 @@
[package]
name = "activitypub"
version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,408 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
const USERS_PATH_PREFIX: &str = "/users/";
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use std::sync::Arc;
use url::Url;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>,
urls: ThoughtsUrls,
event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>,
}
impl ThoughtsObjectHandler {
pub fn new(
repo: Arc<dyn ActivityPubRepository>,
base_url: &str,
event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>,
) -> Self {
Self {
repo,
urls: ThoughtsUrls::new(base_url),
event_publisher,
tag_repo,
}
}
}
#[async_trait]
impl ApObjectHandler for ThoughtsObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<(Url, serde_json::Value)>> {
let uid = UserId::from_uuid(user_id);
let entries = self
.repo
.outbox_entries_for_actor(&uid)
.await
.map_err(|e| anyhow!("{e}"))?;
entries
.into_iter()
.map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
Ok((note_url, serde_json::to_value(&note)?))
})
.collect()
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
let uid = UserId::from_uuid(user_id);
let entries = self
.repo
.outbox_page_for_actor(&uid, before, limit)
.await
.map_err(|e| anyhow!("{e}"))?;
entries
.into_iter()
.map(|e| {
let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
Ok((note_url, serde_json::to_value(&note)?, created_at))
})
.collect()
}
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
let author_id = self
.repo
.intern_remote_actor(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
// Derive visibility from AP addressing conventions.
let as_public = "https://www.w3.org/ns/activitystreams#Public";
let in_to = note.to.iter().any(|s| s == as_public);
let in_cc = note.cc.iter().any(|s| s == as_public);
let has_followers = note.to.iter().any(|s| s.ends_with("/followers"))
|| note.cc.iter().any(|s| s.ends_with("/followers"));
let visibility = if in_to {
"public"
} else if in_cc {
"unlisted"
} else if has_followers {
"followers"
} else {
"direct"
};
let thought_id = self.repo
.accept_note(
ap_id.as_str(),
&author_id,
&note.content,
note.published,
note.sensitive,
note.summary,
visibility,
note.in_reply_to.as_ref().map(|u| u.as_str()),
)
.await
.map_err(|e| anyhow!("{e}"))?;
// Extract and index hashtags from the AP tag array.
let hashtag_names: Vec<String> = note
.tag
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag"))
.filter_map(|t| t.get("name").and_then(|v| v.as_str()))
.map(|name| name.trim_start_matches('#').to_lowercase())
.filter(|name| !name.is_empty())
.collect();
for name in hashtag_names {
if let Ok(tag) = self.tag_repo.find_or_create(&name).await {
let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await;
}
}
// Fire mention notifications for local @mentions in the note's tag array.
let base_url = url::Url::parse(&self.urls.base_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
for tag in &note.tag {
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
continue;
}
let href = match tag.get("href").and_then(|h| h.as_str()) {
Some(h) => h,
None => continue,
};
let href_url = match url::Url::parse(href) {
Ok(u) => u,
Err(_) => continue,
};
if href_url.host_str().unwrap_or("") != base_url {
continue;
}
let user_uuid = href_url
.path()
.strip_prefix(USERS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(uuid) = user_uuid {
self.on_mention(ap_id, uuid, actor_url)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process mention notification");
});
}
}
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
_actor_url: &Url,
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
self.repo
.apply_note_update(ap_id.as_str(), &note.content)
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
self.repo
.retract_note(ap_id.as_str())
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
self.repo
.retract_actor_notes(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
return Ok(());
}
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification");
return Ok(());
}
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let like_id = domain::value_objects::LikeId::new();
ep.publish(&domain::events::DomainEvent::LikeAdded {
like_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_unlike(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_unlike: not a local thought URL, skipping");
return Ok(());
}
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_unlike: remote actor not interned, skipping");
return Ok(());
}
};
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::LikeRemoved {
user_id: actor_user_id,
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_mention(
&self,
thought_ap_id: &url::Url,
mentioned_user_uuid: uuid::Uuid,
actor_url: &url::Url,
) -> anyhow::Result<()> {
let author_user_id = match self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?
{
Some(id) => id,
None => return Ok(()),
};
let thought_uuid = thought_ap_id
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::MentionReceived {
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
author_user_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => return Ok(()),
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let boost_id = domain::value_objects::BoostId::new();
ep.publish(&domain::events::DomainEvent::BoostAdded {
boost_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn count_local_posts(&self) -> Result<u64> {
self.repo
.count_local_notes()
.await
.map_err(|e| anyhow!("{e}"))
}
}

View File

@@ -0,0 +1,7 @@
pub mod handler;
pub mod note;
pub mod urls;
pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote;
pub use urls::ThoughtsUrls;

View File

@@ -0,0 +1,81 @@
use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
/// AP Note representing a Thought.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: Url,
pub url: Url, // Mastodon uses this as the clickable link
pub attributed_to: Url,
pub content: String,
pub published: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tag: Vec<serde_json::Value>,
}
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 {
Self {
kind: Default::default(),
url: id.clone(),
id,
attributed_to: actor_url,
content,
published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to,
sensitive,
summary,
tag: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
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 json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));
assert!(json.contains("\"url\""));
}
}

View File

@@ -0,0 +1,57 @@
use url::Url;
pub struct ThoughtsUrls {
pub base_url: String,
}
impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
}
}
pub fn user_url(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
}
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
}
pub fn user_inbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
}
pub fn user_outbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
}
pub fn user_followers(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(
urls.user_url("alice").as_str(),
"https://example.com/users/alice"
);
}
#[test]
fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil();
assert!(urls
.thought_url(id)
.as_str()
.starts_with("https://example.com/thoughts/"));
}
}

View File

@@ -0,0 +1,19 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
jsonwebtoken = "9"
argon2 = "0.5"
bcrypt = "0.15"
rand = "0.8"
sha2 = "0.10"
hex = "0.4"

View File

@@ -0,0 +1,89 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{ApiKeyRepository, ApiKeyService},
value_objects::UserId,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub struct ApiKeyServiceImpl {
repo: Arc<dyn ApiKeyRepository>,
}
impl ApiKeyServiceImpl {
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
Self { repo }
}
fn hash(raw: &str) -> String {
hex::encode(Sha256::digest(raw.as_bytes()))
}
}
#[async_trait]
impl ApiKeyService for ApiKeyServiceImpl {
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
let hash = Self::hash(raw_key);
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use std::sync::{Arc, Mutex};
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
#[async_trait]
impl ApiKeyRepository for FakeApiKeyRepo {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
self.0.lock().unwrap().push(key.clone());
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())
}
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![])
}
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
Ok(())
}
}
#[tokio::test]
async fn validate_known_key_returns_user_id() {
let uid = UserId::new();
let raw = "super-secret-key";
let hash = ApiKeyServiceImpl::hash(raw);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: uid.clone(),
key_hash: hash,
name: "test".into(),
created_at: Utc::now(),
};
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key(raw).await.unwrap();
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
}
#[tokio::test]
async fn validate_unknown_key_returns_none() {
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key("unknown-key").await.unwrap();
assert!(result.is_none());
}
}

View File

@@ -0,0 +1,123 @@
mod api_key_service;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId},
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
pub use api_key_service::ApiKeyServiceImpl;
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
pub struct JwtAuthService {
secret: String,
ttl_seconds: i64,
}
impl JwtAuthService {
pub fn new(secret: String, ttl_seconds: i64) -> Self {
Self {
secret,
ttl_seconds,
}
}
}
impl AuthService for JwtAuthService {
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize;
let claims = Claims {
sub: user_id.as_uuid().to_string(),
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(GeneratedToken {
token,
user_id: user_id.clone(),
})
}
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| DomainError::Unauthorized)?;
let uuid =
uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
use rand::rngs::OsRng;
let salt = SaltString::generate(OsRng);
let hash = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| DomainError::Internal(e.to_string()))?
.to_string();
Ok(PasswordHash(hash))
}
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
if hash.0.starts_with("$2") {
return bcrypt::verify(plain, &hash.0)
.map_err(|e| DomainError::Internal(e.to_string()));
}
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::AuthService;
#[test]
fn generate_and_validate_token() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let id = UserId::new();
let tok = svc.generate_token(&id).unwrap();
let parsed = svc.validate_token(&tok.token).unwrap();
assert_eq!(parsed.as_uuid(), id.as_uuid());
}
#[test]
fn invalid_token_returns_unauthorized() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let err = svc.validate_token("not.a.token").unwrap_err();
assert!(matches!(err, DomainError::Unauthorized));
}
#[tokio::test]
async fn hash_and_verify() {
let hasher = Argon2PasswordHasher;
let hash = hasher.hash("mypassword").await.unwrap();
assert!(hasher.verify("mypassword", &hash).await.unwrap());
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "event-payload"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,446 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{BoostId, LikeId, ThoughtId, UserId},
};
use serde::{Deserialize, Serialize};
/// Serializable mirror of domain::events::DomainEvent.
/// All IDs are Strings (UUID hex) — no domain type dependencies.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
ThoughtCreated {
thought_id: String,
user_id: String,
in_reply_to_id: Option<String>,
},
ThoughtDeleted {
thought_id: String,
user_id: String,
},
ThoughtUpdated {
thought_id: String,
user_id: String,
},
LikeAdded {
like_id: String,
user_id: String,
thought_id: String,
},
LikeRemoved {
user_id: String,
thought_id: String,
},
BoostAdded {
boost_id: String,
user_id: String,
thought_id: String,
},
BoostRemoved {
user_id: String,
thought_id: String,
},
FollowRequested {
follower_id: String,
following_id: String,
},
FollowAccepted {
follower_id: String,
following_id: String,
},
FollowRejected {
follower_id: String,
following_id: String,
},
Unfollowed {
follower_id: String,
following_id: String,
},
UserBlocked {
blocker_id: String,
blocked_id: String,
},
UserUnblocked {
blocker_id: String,
blocked_id: String,
},
UserRegistered {
user_id: String,
},
ProfileUpdated {
user_id: String,
},
MentionReceived {
thought_id: String,
mentioned_user_id: String,
author_user_id: String,
},
}
impl EventPayload {
/// Returns the NATS subject for this event.
pub fn subject(&self) -> &'static str {
match self {
Self::ThoughtCreated { .. } => "thoughts.created",
Self::ThoughtDeleted { .. } => "thoughts.deleted",
Self::ThoughtUpdated { .. } => "thoughts.updated",
Self::LikeAdded { .. } => "likes.added",
Self::LikeRemoved { .. } => "likes.removed",
Self::BoostAdded { .. } => "boosts.added",
Self::BoostRemoved { .. } => "boosts.removed",
Self::FollowRequested { .. } => "follows.requested",
Self::FollowAccepted { .. } => "follows.accepted",
Self::FollowRejected { .. } => "follows.rejected",
Self::Unfollowed { .. } => "follows.removed",
Self::UserBlocked { .. } => "users.blocked",
Self::UserUnblocked { .. } => "users.unblocked",
Self::UserRegistered { .. } => "users.registered",
Self::ProfileUpdated { .. } => "users.profile_updated",
Self::MentionReceived { .. } => "mentions.received",
}
}
}
// ── DomainEvent → EventPayload ─────────────────────────────────────────────
impl From<&DomainEvent> for EventPayload {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::ThoughtCreated {
thought_id,
user_id,
in_reply_to_id,
} => Self::ThoughtCreated {
thought_id: thought_id.to_string(),
user_id: user_id.to_string(),
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
},
DomainEvent::ThoughtDeleted {
thought_id,
user_id,
} => Self::ThoughtDeleted {
thought_id: thought_id.to_string(),
user_id: user_id.to_string(),
},
DomainEvent::ThoughtUpdated {
thought_id,
user_id,
} => Self::ThoughtUpdated {
thought_id: thought_id.to_string(),
user_id: user_id.to_string(),
},
DomainEvent::LikeAdded {
like_id,
user_id,
thought_id,
} => Self::LikeAdded {
like_id: like_id.to_string(),
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
},
DomainEvent::LikeRemoved {
user_id,
thought_id,
} => Self::LikeRemoved {
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
},
DomainEvent::BoostAdded {
boost_id,
user_id,
thought_id,
} => Self::BoostAdded {
boost_id: boost_id.to_string(),
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
},
DomainEvent::BoostRemoved {
user_id,
thought_id,
} => Self::BoostRemoved {
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
},
DomainEvent::FollowRequested {
follower_id,
following_id,
} => Self::FollowRequested {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
},
DomainEvent::FollowAccepted {
follower_id,
following_id,
} => Self::FollowAccepted {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
},
DomainEvent::FollowRejected {
follower_id,
following_id,
} => Self::FollowRejected {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
},
DomainEvent::Unfollowed {
follower_id,
following_id,
} => Self::Unfollowed {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
},
DomainEvent::UserBlocked {
blocker_id,
blocked_id,
} => Self::UserBlocked {
blocker_id: blocker_id.to_string(),
blocked_id: blocked_id.to_string(),
},
DomainEvent::UserUnblocked {
blocker_id,
blocked_id,
} => Self::UserUnblocked {
blocker_id: blocker_id.to_string(),
blocked_id: blocked_id.to_string(),
},
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
user_id: user_id.to_string(),
},
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
user_id: user_id.to_string(),
},
DomainEvent::MentionReceived {
thought_id,
mentioned_user_id,
author_user_id,
} => Self::MentionReceived {
thought_id: thought_id.to_string(),
mentioned_user_id: mentioned_user_id.to_string(),
author_user_id: author_user_id.to_string(),
},
}
}
}
// ── EventPayload → DomainEvent ─────────────────────────────────────────────
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
Ok(match p {
EventPayload::ThoughtCreated {
thought_id,
user_id,
in_reply_to_id,
} => DomainEvent::ThoughtCreated {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
in_reply_to_id: in_reply_to_id
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
.transpose()?,
},
EventPayload::ThoughtDeleted {
thought_id,
user_id,
} => DomainEvent::ThoughtDeleted {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
},
EventPayload::ThoughtUpdated {
thought_id,
user_id,
} => DomainEvent::ThoughtUpdated {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
},
EventPayload::LikeAdded {
like_id,
user_id,
thought_id,
} => DomainEvent::LikeAdded {
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
},
EventPayload::LikeRemoved {
user_id,
thought_id,
} => DomainEvent::LikeRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
},
EventPayload::BoostAdded {
boost_id,
user_id,
thought_id,
} => DomainEvent::BoostAdded {
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
},
EventPayload::BoostRemoved {
user_id,
thought_id,
} => DomainEvent::BoostRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
},
EventPayload::FollowRequested {
follower_id,
following_id,
} => DomainEvent::FollowRequested {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
},
EventPayload::FollowAccepted {
follower_id,
following_id,
} => DomainEvent::FollowAccepted {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
},
EventPayload::FollowRejected {
follower_id,
following_id,
} => DomainEvent::FollowRejected {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
},
EventPayload::Unfollowed {
follower_id,
following_id,
} => DomainEvent::Unfollowed {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
},
EventPayload::UserBlocked {
blocker_id,
blocked_id,
} => DomainEvent::UserBlocked {
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
},
EventPayload::UserUnblocked {
blocker_id,
blocked_id,
} => DomainEvent::UserUnblocked {
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
},
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
},
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
},
EventPayload::MentionReceived {
thought_id,
mentioned_user_id,
author_user_id,
} => DomainEvent::MentionReceived {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
mentioned_user_id: UserId::from_uuid(parse_uuid(
&mentioned_user_id,
"mentioned_user_id",
)?),
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thought_created_roundtrip() {
let p = EventPayload::ThoughtCreated {
thought_id: "abc".into(),
user_id: "def".into(),
in_reply_to_id: None,
};
let json = serde_json::to_string(&p).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap();
assert_eq!(back.subject(), "thoughts.created");
}
#[test]
fn all_subjects_are_unique() {
let samples: &[EventPayload] = &[
EventPayload::ThoughtCreated {
thought_id: "a".into(),
user_id: "b".into(),
in_reply_to_id: None,
},
EventPayload::ThoughtDeleted {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::ThoughtUpdated {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::LikeAdded {
like_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::LikeRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostAdded {
boost_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::FollowRequested {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowAccepted {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowRejected {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::Unfollowed {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::UserBlocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserUnblocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserRegistered {
user_id: "a".into(),
},
];
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
subjects.sort();
subjects.dedup();
assert_eq!(
subjects.len(),
samples.len(),
"each event must have a unique subject"
);
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "event-transport"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
event-payload = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -0,0 +1,236 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use event_payload::EventPayload;
use futures::stream::BoxStream;
/// Abstraction over any pub/sub transport backend.
/// Implement this for NATS, Kafka, Redis Streams, etc.
/// The adapter calls `publish_bytes(subject, bytes)` — subjects come from `EventPayload::subject()`.
#[async_trait]
pub trait Transport: Send + Sync {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
}
/// Routes domain events to a transport backend.
///
/// Converts: `DomainEvent` → `EventPayload` → JSON bytes → `transport.publish_bytes(subject, bytes)`
///
/// To swap transports (e.g. NATS → Kafka), replace the `T` at the composition root.
pub struct EventPublisherAdapter<T: Transport> {
transport: T,
}
impl<T: Transport> EventPublisherAdapter<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
#[async_trait]
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let subject = payload.subject();
let bytes =
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
tracing::debug!(subject, "publishing event");
self.transport.publish_bytes(subject, &bytes).await
}
}
/// A raw inbound message from a transport backend.
/// `ack` and `nack` are transport-level acknowledgements (e.g. Kafka offset commit).
/// For at-most-once transports (basic NATS), both are no-ops.
pub struct RawMessage {
pub subject: String,
pub payload: Vec<u8>,
pub delivery_count: u64,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
/// Abstraction over any subscribe/consume backend.
pub trait MessageSource: Send + Sync {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
}
/// Deserializes raw transport messages into domain `EventEnvelope`s.
/// Invalid or unknown messages are skipped with a warning — stream continues.
pub struct EventConsumerAdapter<S: MessageSource> {
source: S,
}
impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self {
Self { source }
}
}
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
use futures::StreamExt;
let stream = self.source.messages();
Box::pin(stream.filter_map(|result| async move {
match result {
Err(e) => {
tracing::warn!("transport error: {e}");
None
}
Ok(msg) => {
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
Ok(p) => p,
Err(e) => {
tracing::warn!("failed to deserialize event payload — acking to prevent orphan: {e}");
(msg.ack)();
return None;
}
};
let event = match DomainEvent::try_from(payload) {
Ok(e) => e,
Err(e) => {
tracing::warn!("unknown or malformed event type — acking to prevent orphan: {e}");
(msg.ack)();
return None;
}
};
Some(Ok(EventEnvelope {
event,
delivery_count: msg.delivery_count,
ack: msg.ack,
nack: msg.nack,
}))
}
}
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex};
struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
}
impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
let calls = Arc::new(Mutex::new(vec![]));
(
Self {
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait]
impl Transport for SpyTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.calls
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
#[tokio::test]
async fn thought_created_routes_to_correct_subject() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
})
.await
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "thoughts.created");
}
#[tokio::test]
async fn serialized_payload_is_valid_json() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(),
blocked_id: UserId::new(),
})
.await
.unwrap();
let bytes = calls.lock().unwrap()[0].1.clone();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["type"], "UserBlocked");
}
#[tokio::test]
async fn consumer_adapter_deserializes_and_yields_event() {
use domain::value_objects::ThoughtId;
use futures::StreamExt;
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
let bytes = serde_json::to_vec(&payload).unwrap();
struct OneMessageSource {
bytes: Vec<u8>,
}
#[async_trait::async_trait]
impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "thoughts.created".to_string(),
payload: self.bytes.clone(),
delivery_count: 1,
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(OneMessageSource { bytes });
let mut stream = adapter.consume();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. }));
}
#[tokio::test]
async fn consumer_adapter_skips_invalid_payloads() {
use futures::StreamExt;
struct BadMessageSource;
#[async_trait::async_trait]
impl MessageSource for BadMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "bad".to_string(),
payload: b"not valid json".to_vec(),
delivery_count: 1,
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(BadMessageSource);
let mut stream = adapter.consume();
assert!(stream.next().await.is_none());
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
event-payload = { workspace = true }
event-transport = { workspace = true }
async-nats = { workspace = true }
async-stream = { workspace = true }
serde_json = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,285 @@
use async_nats::jetstream::{self, stream::Config as StreamConfig, AckKind};
use async_trait::async_trait;
use domain::errors::DomainError;
use event_transport::{MessageSource, RawMessage, Transport};
use futures::stream::BoxStream;
use std::sync::Arc;
const STREAM_NAME: &str = "THOUGHTS_EVENTS";
const STREAM_SUBJECT: &str = "thoughts-events.>";
const CONSUMER_NAME: &str = "worker";
const MAX_MESSAGES: i64 = 100_000;
/// Maximum NATS delivery attempts before a message is considered exhausted.
pub const CONSUMER_MAX_DELIVER: i64 = 5;
/// How long NATS waits for an ack before redelivering.
const CONSUMER_ACK_WAIT_SECS: u64 = 30;
/// Timeout for spawned ack/nack async tasks.
const ACK_TASK_TIMEOUT_SECS: u64 = 5;
fn stream_config() -> StreamConfig {
StreamConfig {
name: STREAM_NAME.to_string(),
subjects: vec![STREAM_SUBJECT.to_string()],
max_messages: MAX_MESSAGES,
..Default::default()
}
}
/// Ensure the JetStream stream exists with the current config.
/// If an incompatible stream exists (e.g. wrong retention policy), deletes and recreates it.
pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> {
let js = jetstream::new(client.clone());
// Happy path: stream exists and config is compatible.
if js.update_stream(stream_config()).await.is_ok() {
tracing::info!(subject = STREAM_SUBJECT, "JetStream stream updated");
return Ok(());
}
// Update failed — retention policy mismatch or other incompatibility.
// Delete the old stream and recreate with current config.
tracing::warn!(
"JetStream stream update failed (incompatible config), deleting '{STREAM_NAME}' and recreating"
);
let _ = js.delete_stream(STREAM_NAME).await;
js.create_stream(stream_config())
.await
.map(|_| ())
.map_err(|e| DomainError::Internal(format!("JetStream stream create failed: {e}")))
}
// ── NatsTransport — JetStream publish ──────────────────────────────────────
pub struct NatsTransport {
jetstream: jetstream::Context,
}
impl NatsTransport {
pub fn new(client: async_nats::Client) -> Self {
Self {
jetstream: jetstream::new(client),
}
}
}
#[async_trait]
impl Transport for NatsTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
// Prefix all subjects so they land inside the stream's subject filter.
let full_subject = format!("thoughts-events.{subject}");
self.jetstream
.publish(full_subject, bytes.to_vec().into())
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.await // wait for server ack — confirms message is durably stored
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}
// ── NatsMessageSource — JetStream durable push consumer ────────────────────
pub struct NatsMessageSource {
jetstream: jetstream::Context,
}
impl NatsMessageSource {
pub fn new(client: async_nats::Client) -> Self {
Self {
jetstream: jetstream::new(client),
}
}
}
impl MessageSource for NatsMessageSource {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
use futures::stream;
use tokio::sync::{mpsc, Mutex as TokioMutex};
let js = self.jetstream.clone();
let (tx, rx) = mpsc::channel::<Result<RawMessage, DomainError>>(128);
// Spawn the consumer loop in the background.
// Pull consumer: worker explicitly fetches from NATS rather than NATS pushing.
tokio::spawn(async move {
let stream = match js.get_stream(STREAM_NAME).await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
// Delete any existing push consumer with this name — can't reuse as pull.
// No-op if it doesn't exist or is already a pull consumer.
if let Ok(info) = stream.consumer_info(CONSUMER_NAME).await {
if info.config.deliver_subject.is_some() {
tracing::info!(
"deleting old push consumer '{CONSUMER_NAME}', replacing with pull"
);
let _ = stream.delete_consumer(CONSUMER_NAME).await;
}
}
let consumer = match stream
.get_or_create_consumer(
CONSUMER_NAME,
jetstream::consumer::pull::Config {
durable_name: Some(CONSUMER_NAME.to_string()),
deliver_policy: jetstream::consumer::DeliverPolicy::New,
ack_policy: jetstream::consumer::AckPolicy::Explicit,
ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS),
max_deliver: CONSUMER_MAX_DELIVER,
// No filter_subject — consume everything from the stream.
// filter_subject matching the stream's own wildcard can be
// inconsistent across NATS server versions.
..Default::default()
},
)
.await
{
Ok(c) => c,
Err(e) => {
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
tracing::info!("NATS pull consumer ready");
loop {
// consumer.messages() uses long-poll (no no_wait flag) — NATS holds the
// request open and delivers messages as they arrive.
// fetch() in async-nats 0.48 defaults to no_wait:true which returns
// immediately when the queue is empty, so we avoid it here.
let mut messages = match consumer.messages().await {
Ok(m) => m,
Err(e) => {
tracing::error!("NATS messages() failed: {e}");
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
use futures::StreamExt;
while let Some(result) = messages.next().await {
let msg = match result {
Ok(m) => m,
Err(e) => {
tracing::warn!("NATS message error: {e}");
continue;
}
};
let subject = msg.subject.to_string();
let payload = msg.payload.to_vec();
let delivery_count = msg
.info()
.map(|info| info.delivered.max(0) as u64)
.unwrap_or(1);
let msg = Arc::new(msg);
let msg_nack = Arc::clone(&msg);
let raw = RawMessage {
subject,
payload,
delivery_count,
ack: Box::new(move || {
let m = Arc::clone(&msg);
tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
m.ack(),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"),
Err(_) => tracing::warn!(
"NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
}
});
}),
nack: Box::new(move || {
let m = Arc::clone(&msg_nack);
tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
m.ack_with(AckKind::Nak(None)),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"),
Err(_) => tracing::warn!(
"NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
}
});
}),
};
if tx.send(Ok(raw)).await.is_err() {
return; // receiver dropped — worker shutting down
}
}
// messages() stream ended (e.g. fetch timeout) — loop and restart
}
});
// Bridge the channel receiver into a BoxStream.
let rx = Arc::new(TokioMutex::new(rx));
Box::pin(stream::unfold(rx, |rx| async move {
let item = rx.lock().await.recv().await?;
Some((item, rx))
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
events::DomainEvent,
value_objects::{LikeId, ThoughtId, UserId},
};
use event_payload::EventPayload;
#[test]
fn payload_from_domain_event_has_correct_subject() {
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
assert_eq!(payload.subject(), "thoughts.created");
}
#[test]
fn domain_event_roundtrip_via_payload() {
let uid = UserId::new();
let tid = ThoughtId::new();
let event = DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: uid.clone(),
thought_id: tid.clone(),
};
let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::LikeAdded {
user_id,
thought_id,
..
} = back
{
assert_eq!(user_id, uid);
assert_eq!(thought_id, tid);
} else {
panic!("wrong variant");
}
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "postgres-federation"
version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
url = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["migrate"] }

View File

@@ -0,0 +1,574 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
FollowingStatus, RemoteActor,
};
// ── PostgresFederationRepository ─────────────────────────────────────────────
pub struct PostgresFederationRepository {
pool: PgPool,
}
impl PostgresFederationRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn status_str(s: &FollowerStatus) -> &'static str {
match s {
FollowerStatus::Pending => "pending",
FollowerStatus::Accepted => "accepted",
FollowerStatus::Rejected => "rejected",
}
}
fn str_status(s: &str) -> FollowerStatus {
match s {
"accepted" => FollowerStatus::Accepted,
"rejected" => FollowerStatus::Rejected,
_ => FollowerStatus::Pending,
}
}
fn map_remote_actor(
url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
) -> RemoteActor {
RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url,
}
}
#[async_trait]
impl FederationRepository for PostgresFederationRepository {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
VALUES($1,$2,$3,$4)
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id"
)
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()> {
sqlx::query(
"DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2",
)
.bind(local_user_id)
.bind(remote_actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)]
struct Row {
remote_actor_url: String,
status: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted'"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
status: str_status(&r.status),
}).collect())
}
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)]
struct Row {
remote_actor_url: String,
status: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
status: str_status(&r.status),
}).collect())
}
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'"
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='pending'"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
self.upsert_remote_actor(actor.clone()).await?;
sqlx::query(
"INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url)
VALUES($1,$2,$3,$4)
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
SET follow_activity_id=EXCLUDED.follow_activity_id"
)
.bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query(
"DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
)
.bind(local_user_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1 AND status='accepted'")
.bind(local_user_id)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn update_following_status(
&self,
_local_user_id: uuid::Uuid,
_remote_actor_url: &str,
_status: FollowingStatus,
) -> Result<()> {
Ok(())
}
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()"
)
.bind(&actor.url).bind(&actor.handle).bind(&actor.display_name)
.bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
.bind(&actor.avatar_url).bind(&actor.outbox_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row {
url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>(
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
))
}
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
#[derive(sqlx::FromRow)]
struct Row {
public_key: Option<String>,
private_key: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.and_then(|r| match (r.public_key, r.private_key) {
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
_ => None,
}))
}
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
.bind(user_id)
.bind(&public_key)
.bind(&private_key)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: DateTime<Utc>,
) -> Result<()> {
sqlx::query(
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(announced_at)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1")
.bind(object_url)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
sqlx::query(
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING"
).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
.bind(domain)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
#[derive(sqlx::FromRow)]
struct Row {
domain: String,
reason: Option<String>,
blocked_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain",
)
.fetch_all(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|rows| {
rows.into_iter()
.map(|r| BlockedDomain {
domain: r.domain,
reason: r.reason,
blocked_at: r.blocked_at.to_rfc3339(),
})
.collect()
})
}
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
.bind(domain)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n > 0)
}
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query(
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING"
).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
.bind(local_user_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
}
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
sqlx::query_scalar::<_, String>(
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2"
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n > 0)
}
}
// ── PostgresApUserRepository ──────────────────────────────────────────────────
pub struct PostgresApUserRepository {
pool: PgPool,
base_url: String,
}
impl PostgresApUserRepository {
pub fn new(pool: PgPool, base_url: String) -> Self {
Self { pool, base_url }
}
fn row_to_ap_user(
&self,
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
) -> ApUser {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
ApUser {
id,
username,
bio,
avatar_url,
banner_url: None,
also_known_as: None,
profile_url,
attachment: vec![],
}
}
}
#[async_trait]
impl ApUserRepository for PostgresApUserRepository {
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
}
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true",
)
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
}
async fn count_users(&self) -> Result<usize> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "postgres-search"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
postgres = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["migrate"] }
postgres = { workspace = true }

View File

@@ -0,0 +1,372 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, PageParams, Paginated},
thought::{Thought, Visibility},
user::User,
},
ports::SearchPort,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
use postgres::user::{UserRow, USER_SELECT};
use sqlx::PgPool;
pub struct PgSearchRepository {
pool: PgPool,
}
impl PgSearchRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct FeedRow {
thought_id: uuid::Uuid,
t_user_id: uuid::Uuid,
content: String,
in_reply_to_id: Option<uuid::Uuid>,
visibility: String,
content_warning: Option<String>,
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
let viewer_checks = match viewer {
Some(uid) => format!(
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
};
format!(
"\n SELECT\n\
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
t.in_reply_to_id,\n\
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
t.created_at AS thought_created_at, t.updated_at,\n\
u.id AS author_id, u.username, u.email, u.password_hash,\n\
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
u.local AS author_local,\n\
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
{viewer_checks}\n\
FROM thoughts t JOIN users u ON u.id=t.user_id"
)
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id),
user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::from_db_str(&r.visibility)?,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.updated_at,
};
let author = User {
id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
Ok(FeedEntry {
thought,
author,
stats: domain::models::feed::EngagementStats {
like_count: r.like_count,
boost_count: r.boost_count,
reply_count: r.reply_count,
},
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
liked: r.liked_by_viewer,
boosted: r.boosted_by_viewer,
}),
})
}
#[async_trait]
impl SearchPort for PgSearchRepository {
async fn search_thoughts(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let select = feed_select(viewer);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t
WHERE t.content % $1 AND t.visibility='public'",
)
.bind(query)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sql = format!(
"{select}
WHERE t.content % $1 AND t.visibility='public'
ORDER BY similarity(t.content, $1) DESC
LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
async fn search_users(
&self,
query: &str,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users u
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)",
)
.bind(query)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sql = format!(
"{USER_SELECT}
WHERE local=true AND (username % $1 OR display_name % $1)
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, UserRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(User::from).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserWriter},
value_objects::*,
};
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
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(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
seed_thought(&pool, "bob", "goodbye universe").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
use postgres::user::PgUserRepository;
let urepo = PgUserRepository::new(pool.clone());
let alice = User::new_local(
UserId::new(),
Username::new("alice_search").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&alice).await.unwrap();
let repo = PgSearchRepository::new(pool);
let result = repo
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(!result.items.is_empty());
assert!(result
.items
.iter()
.any(|u| u.username.as_str() == "alice_search"));
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"zzzzzzzzz",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
use domain::models::social::Like;
use domain::ports::{LikeRepository, UserWriter};
use domain::value_objects::LikeId;
use postgres::{like::PgLikeRepository, user::PgUserRepository};
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
// alice likes her own thought
let like_repo = PgLikeRepository::new(pool.clone());
like_repo
.save(&Like {
id: LikeId::new(),
user_id: alice.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: chrono::Utc::now(),
})
.await
.unwrap();
let repo = PgSearchRepository::new(pool);
// with viewer — should see liked = true
let authed = repo
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
Some(&alice.id),
)
.await
.unwrap();
assert_eq!(authed.items.len(), 1);
let ctx = authed.items[0]
.viewer
.as_ref()
.expect("viewer context present");
assert!(ctx.liked, "alice should see the thought as liked");
assert!(!ctx.boosted);
// without viewer — viewer should be None
let anon = repo
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(anon.items.len(), 1);
assert!(
anon.items[0].viewer.is_none(),
"anonymous request has no viewer context"
);
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "postgres"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
activitypub-base = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["migrate"] }

View File

@@ -0,0 +1,55 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(50),
bio VARCHAR(160),
avatar_url TEXT,
header_url TEXT,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS thoughts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE IF NOT EXISTS top_friends (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position SMALLINT NOT NULL,
PRIMARY KEY (user_id, friend_id),
UNIQUE (user_id, position)
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS thought_tags (
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (thought_id, tag_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,21 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS inbox_url TEXT,
ADD COLUMN IF NOT EXISTS public_key TEXT,
ADD COLUMN IF NOT EXISTS private_key TEXT,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE thoughts
ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id),
ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT,
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public',
ADD COLUMN IF NOT EXISTS content_warning TEXT,
ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
ALTER TABLE follows
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted',
ADD COLUMN IF NOT EXISTS ap_id TEXT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

View File

@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS boosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS blocks (
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
display_name TEXT,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
public_key TEXT NOT NULL,
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
from_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE,
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id);
CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read);

View File

@@ -0,0 +1,11 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_thoughts_content_trgm
ON thoughts USING GIN(content gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_username_trgm
ON users USING GIN(username gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_display_name_trgm
ON users USING GIN(display_name gin_trgm_ops)
WHERE display_name IS NOT NULL;

View File

@@ -0,0 +1,54 @@
-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them)
ALTER TABLE remote_actors
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
ADD COLUMN IF NOT EXISTS outbox_url TEXT;
-- Federation followers: remote actors following local users
CREATE TABLE IF NOT EXISTS federation_followers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
follow_activity_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Federation following: local users following remote actors
CREATE TABLE IF NOT EXISTS federation_following (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
outbox_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Announces (boosts of remote objects via AP)
CREATE TABLE IF NOT EXISTS federation_announces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id TEXT NOT NULL UNIQUE,
object_url TEXT NOT NULL,
actor_url TEXT NOT NULL,
announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked domains (instance-level)
CREATE TABLE IF NOT EXISTS federation_blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked actors (per local user)
CREATE TABLE IF NOT EXISTS federation_blocked_actors (
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (local_user_id, actor_url)
);
CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url);

View File

@@ -0,0 +1,13 @@
CREATE TABLE remote_actor_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_url TEXT NOT NULL,
connection_type TEXT NOT NULL,
page INT NOT NULL,
connected_actor_url TEXT NOT NULL,
connected_handle TEXT NOT NULL,
connected_display_name TEXT,
connected_avatar_url TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(actor_url, connection_type, page, connected_actor_url)
);
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);

View File

@@ -0,0 +1,3 @@
-- Remote ActivityPub posts can exceed 128 characters.
-- The 128-char limit is enforced at the application layer for local posts only.
ALTER TABLE thoughts ALTER COLUMN content TYPE TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;

View File

@@ -0,0 +1,15 @@
CREATE TABLE failed_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
failed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retry_at TIMESTAMPTZ NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL,
CONSTRAINT failed_events_pkey PRIMARY KEY (id)
);
CREATE INDEX failed_events_due_idx
ON failed_events (retry_at)
WHERE retry_count < 3;

View File

@@ -0,0 +1,11 @@
-- Change in_reply_to_id FK from RESTRICT (default) to SET NULL.
-- Previously, deleting a thought that had replies raised a FK violation.
-- With SET NULL, deleting a thought orphans its replies (they survive but
-- lose their parent reference), which is the correct semantic for a
-- threaded social app.
ALTER TABLE thoughts
DROP CONSTRAINT IF EXISTS thoughts_in_reply_to_id_fkey;
ALTER TABLE thoughts
ADD CONSTRAINT thoughts_in_reply_to_id_fkey
FOREIGN KEY (in_reply_to_id) REFERENCES thoughts(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,10 @@
CREATE TABLE outbox_events (
seq BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered BOOLEAN NOT NULL DEFAULT false,
delivered_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false;

View File

@@ -0,0 +1,406 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
const MAX_REMOTE_CONTENT_CHARS: usize = 500;
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
value_objects::{Content, ThoughtId, UserId, Username},
};
pub struct PgActivityPubRepository {
pool: PgPool,
}
impl PgActivityPubRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ActivityPubRepository for PgActivityPubRepository {
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
content: String,
created_at: DateTime<Utc>,
in_reply_to_id: Option<uuid::Uuid>,
content_warning: Option<String>,
sensitive: bool,
username: String,
updated_at: Option<DateTime<Utc>>,
}
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC",
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.into_domain()
.map(|rows| {
rows.into_iter()
.map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
})
.collect()
})
}
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
content: String,
created_at: DateTime<Utc>,
in_reply_to_id: Option<uuid::Uuid>,
content_warning: Option<String>,
sensitive: bool,
username: String,
updated_at: Option<DateTime<Utc>>,
}
let rows = if let Some(before) = before {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
ORDER BY t.created_at DESC LIMIT $3",
)
.bind(user_id.as_uuid())
.bind(before)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC LIMIT $2",
)
.bind(user_id.as_uuid())
.bind(limit as i64)
.fetch_all(&self.pool)
.await
}
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
})
.collect())
}
async fn find_remote_actor_id(
&self,
actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url)
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(UserId::from_uuid))
}
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(id);
}
let new_id = uuid::Uuid::new_v4();
// Use the last path segment as username (e.g. /users/alice → "alice").
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
// username column is VARCHAR(32).
let last_seg = url::Url::parse(actor_ap_url)
.ok()
.and_then(|u| {
u.path_segments()
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
})
.unwrap_or_default();
let handle = if last_seg.is_empty() {
format!("remote_{}", &new_id.to_string()[..13])
} else if last_seg.len() <= 32 {
last_seg
} else {
format!("remote_{}", &new_id.to_string()[..13])
};
sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(new_id)
.bind(&handle)
.bind(format!("{}@remote", new_id))
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()?;
// Re-fetch to get whichever id won the race
self.find_remote_actor_id(actor_ap_url)
.await?
.ok_or_else(|| {
DomainError::Internal(
"intern_remote_actor: insert succeeded but row not found".into(),
)
})
}
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name=$1, avatar_url=$2, updated_at=NOW()
WHERE id=$3 AND local=false",
)
.bind(display_name)
.bind(avatar_url)
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.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> {
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) => {
// If the parent is a local thought, extract its UUID for in_reply_to_id.
let local_uuid = url::Url::parse(url).ok().and_then(|u| {
u.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
});
(local_uuid, Some(url.to_string()))
}
None => (None, None),
};
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url)
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(uuid::Uuid::new_v4())
.bind(author_id.as_uuid())
.bind(&capped)
.bind(ap_id)
.bind(sensitive)
.bind(content_warning)
.bind(published)
.bind(visibility)
.bind(in_reply_to_id)
.bind(&in_reply_to_url)
.execute(&self.pool)
.await
.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()?;
Ok(ThoughtId::from_uuid(row.0))
}
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
sqlx::query(
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
)
.bind(ap_id)
.bind(&capped)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
)
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(n as u64)
}
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
)
.bind(thought_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
}
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
sqlx::query_as::<_, (String, String)>(
"SELECT ap_id, inbox_url FROM users \
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
)
.bind(user_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use activitypub_base::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 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,
)
.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();
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);
}
}

View File

@@ -0,0 +1,142 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use sqlx::PgPool;
pub struct PgApiKeyRepository {
pool: PgPool,
}
impl PgApiKeyRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PgApiKeyRepository {
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
)
.bind(k.id.as_uuid())
.bind(k.user_id.as_uuid())
.bind(&k.key_hash)
.bind(&k.name)
.bind(k.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
)
.bind(hash)
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| {
o.map(|r| ApiKey {
id: ApiKeyId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
key_hash: r.key_hash,
name: r.name,
created_at: r.created_at,
})
})
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
.into_domain()
.map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect())
}
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
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
}
#[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());
}
}

View File

@@ -0,0 +1,90 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
};
use sqlx::PgPool;
pub struct PgBlockRepository {
pool: PgPool,
}
impl PgBlockRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BlockRepository for PgBlockRepository {
async fn save(&self, b: &Block) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
)
.bind(b.blocker_id.as_uuid())
.bind(b.blocked_id.as_uuid())
.bind(b.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(count > 0)
}
}
#[cfg(test)]
mod tests {
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 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

@@ -0,0 +1,110 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Boost,
ports::BoostRepository,
value_objects::{BoostId, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgBoostRepository {
pool: PgPool,
}
impl PgBoostRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BoostRepository for PgBoostRepository {
async fn save(&self, b: &Boost) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
)
.bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at)
.execute(&self.pool).await.into_domain().map(|_| ())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid())
.bind(thought_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Boost>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await
.into_domain()
.map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
.bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()
}
}
#[cfg(test)]
mod tests {
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 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

@@ -0,0 +1,20 @@
use domain::errors::DomainError;
pub(crate) trait IntoDbResult<T> {
fn into_domain(self) -> Result<T, DomainError>;
}
impl<T> IntoDbResult<T> for Result<T, sqlx::Error> {
fn into_domain(self) -> Result<T, DomainError> {
self.map_err(|e| {
if let sqlx::Error::Database(ref db) = e {
if db.code().as_deref() == Some("23505") {
return DomainError::Conflict(
db.constraint().unwrap_or("conflict").to_string(),
);
}
}
DomainError::Internal(e.to_string())
})
}
}

View File

@@ -0,0 +1,83 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::feed::{EngagementStats, ViewerContext},
ports::EngagementRepository,
value_objects::{ThoughtId, UserId},
};
use sqlx::PgPool;
use std::collections::HashMap;
pub struct PgEngagementRepository {
pool: PgPool,
}
impl PgEngagementRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl EngagementRepository for PgEngagementRepository {
async fn get_for_thoughts(
&self,
thought_ids: &[ThoughtId],
viewer_id: Option<&UserId>,
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError> {
if thought_ids.is_empty() {
return Ok(HashMap::new());
}
#[derive(sqlx::FromRow)]
struct Row {
thought_id: uuid::Uuid,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
let ids: Vec<uuid::Uuid> = thought_ids.iter().map(|t| t.as_uuid()).collect();
let viewer_uuid: Option<uuid::Uuid> = viewer_id.map(|v| v.as_uuid());
let rows = sqlx::query_as::<_, Row>(
"SELECT
t.id AS thought_id,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT b.user_id) AS boost_count,
COUNT(DISTINCT r.id) AS reply_count,
COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer,
COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer
FROM thoughts t
LEFT JOIN likes l ON l.thought_id = t.id
LEFT JOIN boosts b ON b.thought_id = t.id
LEFT JOIN thoughts r ON r.in_reply_to_id = t.id
WHERE t.id = ANY($1)
GROUP BY t.id",
)
.bind(&ids[..])
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
let mut result = HashMap::new();
for row in rows {
let tid = ThoughtId::from_uuid(row.thought_id);
let stats = EngagementStats {
like_count: row.like_count,
boost_count: row.boost_count,
reply_count: row.reply_count,
};
let viewer = viewer_id.map(|_| ViewerContext {
liked: row.liked_by_viewer,
boosted: row.boosted_by_viewer,
});
result.insert(tid, (stats, viewer));
}
Ok(result)
}
}

View File

@@ -0,0 +1,105 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
/// How many times a failed event is retried by the DLQ processor.
pub const DLQ_MAX_RETRIES: i32 = 3;
/// Quarantine period for the first DLQ retry (seconds). Doubles each retry.
pub const DLQ_INITIAL_BACKOFF_SECS: i64 = 300; // 5 minutes
/// How often the DLQ processor polls for due retries (seconds).
pub const DLQ_POLL_INTERVAL_SECS: u64 = 60;
#[derive(sqlx::FromRow)]
pub struct FailedEvent {
pub id: uuid::Uuid,
pub event_type: String,
pub payload: serde_json::Value,
pub failed_at: DateTime<Utc>,
pub retry_at: DateTime<Utc>,
pub retry_count: i32,
pub last_error: String,
}
pub struct PgFailedEventStore {
pool: PgPool,
}
impl PgFailedEventStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Insert a newly exhausted event into the DLQ.
pub async fn insert(
&self,
event_type: &str,
payload: &serde_json::Value,
last_error: &str,
) -> Result<(), sqlx::Error> {
let retry_at = Utc::now() + chrono::Duration::seconds(DLQ_INITIAL_BACKOFF_SECS);
sqlx::query(
"INSERT INTO failed_events \
(event_type, payload, retry_at, last_error) \
VALUES ($1, $2, $3, $4)",
)
.bind(event_type)
.bind(payload)
.bind(retry_at)
.bind(last_error)
.execute(&self.pool)
.await?;
Ok(())
}
/// Fetch all events due for retry (retry_at <= now, retry_count < DLQ_MAX_RETRIES).
pub async fn poll_due(&self) -> Result<Vec<FailedEvent>, sqlx::Error> {
sqlx::query_as::<_, FailedEvent>(
"SELECT id, event_type, payload, failed_at, retry_at, retry_count, last_error \
FROM failed_events \
WHERE retry_at <= now() AND retry_count < $1 \
ORDER BY retry_at \
LIMIT 100",
)
.bind(DLQ_MAX_RETRIES)
.fetch_all(&self.pool)
.await
}
/// Advance a row after a republish attempt using exponential backoff.
/// next_retry = now + initial * 2^retry_count
pub async fn advance(&self, id: uuid::Uuid, error: Option<&str>) -> Result<(), sqlx::Error> {
let current: i32 =
sqlx::query_scalar("SELECT retry_count FROM failed_events WHERE id = $1")
.bind(id)
.fetch_one(&self.pool)
.await?;
let new_count = current + 1;
let backoff_secs = DLQ_INITIAL_BACKOFF_SECS * (1_i64 << new_count.min(10));
let retry_at = Utc::now() + chrono::Duration::seconds(backoff_secs);
let last_error = error.unwrap_or("republish succeeded");
sqlx::query(
"UPDATE failed_events \
SET retry_count = $1, retry_at = $2, last_error = $3 \
WHERE id = $4",
)
.bind(new_count)
.bind(retry_at)
.bind(last_error)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Park a permanently failed event (retry_count >= DLQ_MAX_RETRIES).
pub async fn park_permanently(&self, id: uuid::Uuid) -> Result<(), sqlx::Error> {
let far_future = Utc::now() + chrono::Duration::days(365);
sqlx::query("UPDATE failed_events SET retry_at = $1 WHERE id = $2")
.bind(far_future)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,399 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, Paginated},
thought::{Thought, Visibility},
user::User,
},
ports::{FeedQuery, FeedRepository, FeedScope},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
use sqlx::PgPool;
pub struct PgFeedRepository {
pool: PgPool,
}
impl PgFeedRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct FeedRow {
thought_id: uuid::Uuid,
t_user_id: uuid::Uuid,
content: String,
in_reply_to_id: Option<uuid::Uuid>,
visibility: String,
content_warning: Option<String>,
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
match follower {
Some(fid) => format!(
" OR t.user_id IN (
SELECT u2.id FROM users u2
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
WHERE ff.local_user_id = '{fid}'
)"
),
None => String::new(),
}
}
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
let viewer_checks = match viewer {
Some(uid) => format!(
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
};
format!(
"
SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at,
u.id AS author_id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
CASE WHEN ra.handle NOT LIKE '%@%'
THEN '@' || SPLIT_PART(ra.url, '/', 3)
ELSE '' END
ELSE u.username END AS username,
u.email, u.password_hash,
COALESCE(ra.display_name, u.display_name) AS display_name,
u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
u.header_url, u.custom_css,
u.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
{viewer_checks}
FROM thoughts t
JOIN users u ON u.id=t.user_id
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
)
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id),
user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::from_db_str(&r.visibility)?,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.updated_at,
};
let author = User {
id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
Ok(FeedEntry {
thought,
author,
stats: domain::models::feed::EngagementStats {
like_count: r.like_count,
boost_count: r.boost_count,
reply_count: r.reply_count,
},
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
liked: r.liked_by_viewer,
boosted: r.boosted_by_viewer,
}),
})
}
#[async_trait]
impl FeedRepository for PgFeedRepository {
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
let page = &q.page;
match &q.scope {
FeedScope::Home { following_ids } => {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let fed_clause = federation_following_clause(viewer);
let count_sql = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
fed_clause
);
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Public => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Search { query } => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
)
.bind(query)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Tag { tag_name } => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'",
)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!(
"{sel}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(tag_name)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::User { user_id } => {
let uid = user_id.as_uuid();
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
)
.bind(uid)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{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(),
None,
Visibility::Public,
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"));
}
}

View File

@@ -0,0 +1,252 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
social::{Follow, FollowState},
user::User,
},
ports::FollowRepository,
value_objects::UserId,
};
use sqlx::PgPool;
pub struct PgFollowRepository {
pool: PgPool,
}
impl PgFollowRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl FollowRepository for PgFollowRepository {
async fn save(&self, f: &Follow) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO follows(follower_id,following_id,state,ap_id,created_at)
VALUES($1,$2,$3,$4,$5)
ON CONFLICT(follower_id,following_id) DO UPDATE SET state=EXCLUDED.state,ap_id=EXCLUDED.ap_id"
)
.bind(f.follower_id.as_uuid())
.bind(f.following_id.as_uuid())
.bind(f.state.as_str())
.bind(&f.ap_id)
.bind(f.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM follows WHERE follower_id=$1 AND following_id=$2")
.bind(follower_id.as_uuid())
.bind(following_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn find(
&self,
follower_id: &UserId,
following_id: &UserId,
) -> Result<Option<Follow>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
follower_id: uuid::Uuid,
following_id: uuid::Uuid,
state: String,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2"
)
.bind(follower_id.as_uuid())
.bind(following_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.and_then(|o| {
o.map(|r| {
Ok(Follow {
follower_id: UserId::from_uuid(r.follower_id),
following_id: UserId::from_uuid(r.following_id),
state: FollowState::from_db_str(&r.state)?,
ap_id: r.ap_id,
created_at: r.created_at,
})
})
.transpose()
})
}
async fn update_state(
&self,
follower_id: &UserId,
following_id: &UserId,
state: &FollowState,
) -> Result<(), DomainError> {
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
.bind(follower_id.as_uuid())
.bind(following_id.as_uuid())
.bind(state.as_str())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn list_followers(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'",
)
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.follower_id=u.id
WHERE f.following_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id.as_uuid())
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows.into_iter().map(User::from).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn list_following(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'",
)
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.following_id=u.id
WHERE f.follower_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id.as_uuid())
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows.into_iter().map(User::from).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn get_accepted_following_ids(
&self,
user_id: &UserId,
) -> Result<Vec<UserId>, DomainError> {
let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'",
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(ids.into_iter().map(UserId::from_uuid).collect())
}
}
#[cfg(test)]
mod tests {
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 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]);
}
}

View File

@@ -0,0 +1,20 @@
pub mod activitypub;
pub mod engagement;
pub mod api_key;
pub mod block;
pub mod boost;
mod db_error;
pub mod failed_event;
pub mod outbox;
pub mod feed;
pub mod follow;
pub mod like;
pub mod notification;
pub mod remote_actor;
pub mod remote_actor_connections;
pub mod tag;
#[cfg(test)]
pub(crate) mod test_helpers;
pub mod thought;
pub mod top_friend;
pub mod user;

View File

@@ -0,0 +1,110 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Like,
ports::LikeRepository,
value_objects::{LikeId, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgLikeRepository {
pool: PgPool,
}
impl PgLikeRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl LikeRepository for PgLikeRepository {
async fn save(&self, l: &Like) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO likes(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
)
.bind(l.id.as_uuid()).bind(l.user_id.as_uuid()).bind(l.thought_id.as_uuid()).bind(&l.ap_id).bind(l.created_at)
.execute(&self.pool).await.into_domain().map(|_| ())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid())
.bind(thought_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Like>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await
.into_domain()
.map(|o| o.map(|r| Like { id: LikeId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1")
.bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()
}
}
#[cfg(test)]
mod tests {
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 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

@@ -0,0 +1,230 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
notification::{Notification, NotificationKind},
},
ports::NotificationRepository,
value_objects::{NotificationId, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgNotificationRepository {
pool: PgPool,
}
impl PgNotificationRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct NotificationRow {
id: uuid::Uuid,
user_id: uuid::Uuid,
notification_type: String,
from_user_id: Option<uuid::Uuid>,
thought_id: Option<uuid::Uuid>,
read: bool,
created_at: DateTime<Utc>,
}
fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
let from_user_id = r
.from_user_id
.map(UserId::from_uuid)
.ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;
let kind = match r.notification_type.as_str() {
"follow" => NotificationKind::Follow { from_user_id },
other => {
let thought_id = r.thought_id.map(ThoughtId::from_uuid).ok_or_else(|| {
DomainError::Internal(format!("notification type '{other}' missing thought_id"))
})?;
match other {
"like" => NotificationKind::Like {
thought_id,
from_user_id,
},
"boost" => NotificationKind::Boost {
thought_id,
from_user_id,
},
"reply" => NotificationKind::Reply {
thought_id,
from_user_id,
},
"mention" => NotificationKind::Mention {
thought_id,
from_user_id,
},
_ => {
return Err(DomainError::Internal(format!(
"unknown notification type: {other}"
)))
}
}
}
};
Ok(Notification {
id: NotificationId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
kind,
read: r.read,
created_at: r.created_at,
})
}
#[async_trait]
impl NotificationRepository for PgNotificationRepository {
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(id) DO NOTHING"
)
.bind(n.id.as_uuid())
.bind(n.user_id.as_uuid())
.bind(n.kind.kind_str())
.bind(n.kind.from_user_id().as_uuid())
.bind(n.kind.thought_id().map(|t| t.as_uuid()))
.bind(n.read)
.bind(n.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn list_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Notification>, DomainError> {
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1")
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, NotificationRow>(
"SELECT id,user_id,notification_type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset())
.fetch_all(&self.pool).await.into_domain()?;
let items = rows
.into_iter()
.map(row_to_notification)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
}
async fn count_unread(&self, user_id: &UserId) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM notifications WHERE user_id=$1 AND read=false",
)
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(count as u64)
}
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1")
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
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(),
};
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);
}
#[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,
},
)
.await
.unwrap();
assert!(page.items[0].read);
}
}

View File

@@ -0,0 +1,61 @@
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::OutboxWriter};
use event_payload::EventPayload;
use sqlx::PgPool;
use uuid::Uuid;
pub struct PgOutboxWriter {
pool: PgPool,
}
impl PgOutboxWriter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// Primary aggregate UUID for an event — used to populate `aggregate_id`.
fn aggregate_id(event: &DomainEvent) -> Uuid {
match event {
DomainEvent::ThoughtCreated { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::ThoughtDeleted { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::ThoughtUpdated { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::LikeAdded { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::LikeRemoved { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::BoostAdded { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::BoostRemoved { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::FollowRequested { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::FollowAccepted { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::FollowRejected { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::Unfollowed { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::UserBlocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserRegistered { user_id } => user_id.as_uuid(),
DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(),
DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(),
}
}
#[async_trait]
impl OutboxWriter for PgOutboxWriter {
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let event_type = payload.subject();
let payload_json =
serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
let agg_id = aggregate_id(event);
sqlx::query(
"INSERT INTO outbox_events (aggregate_id, event_type, payload) \
VALUES ($1, $2, $3)",
)
.bind(agg_id)
.bind(event_type)
.bind(payload_json)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,59 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository,
};
use sqlx::PgPool;
pub struct PgRemoteActorRepository {
pool: PgPool,
}
impl PgRemoteActorRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl RemoteActorRepository for PgRemoteActorRepository {
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at)
VALUES($1,$2,$3,$4,$5)
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
)
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at)
.execute(&self.pool).await.into_domain().map(|_| ())
}
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
url: String,
handle: String,
display_name: Option<String>,
avatar_url: Option<String>,
last_fetched_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
).bind(url).fetch_optional(&self.pool).await
.into_domain()
.map(|o| o.map(|r| RemoteActor {
url: r.url,
handle: r.handle,
display_name: r.display_name,
avatar_url: r.avatar_url,
last_fetched_at: r.last_fetched_at,
bio: None,
banner_url: None,
also_known_as: None,
outbox_url: None,
followers_url: None,
following_url: None,
attachment: vec![],
}))
}
}

View File

@@ -0,0 +1,111 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError, models::actor_connection_summary::ActorConnectionSummary,
ports::RemoteActorConnectionRepository,
};
use sqlx::PgPool;
pub struct PgRemoteActorConnectionRepository {
pool: PgPool,
}
impl PgRemoteActorConnectionRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository {
async fn upsert_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
actors: &[ActorConnectionSummary],
) -> Result<(), DomainError> {
for actor in actors {
sqlx::query(
"INSERT INTO remote_actor_connections
(actor_url, connection_type, page, connected_actor_url,
connected_handle, connected_display_name, connected_avatar_url, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT(actor_url, connection_type, page, connected_actor_url)
DO UPDATE SET
connected_handle = EXCLUDED.connected_handle,
connected_display_name = EXCLUDED.connected_display_name,
connected_avatar_url = EXCLUDED.connected_avatar_url,
fetched_at = NOW()",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.bind(&actor.url)
.bind(&actor.handle)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.execute(&self.pool)
.await
.into_domain()?;
}
Ok(())
}
async fn list_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Vec<ActorConnectionSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
connected_actor_url: String,
connected_handle: String,
connected_display_name: Option<String>,
connected_avatar_url: Option<String>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url
FROM remote_actor_connections
WHERE actor_url = $1 AND connection_type = $2 AND page = $3
ORDER BY connected_handle",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| ActorConnectionSummary {
url: r.connected_actor_url,
handle: r.connected_handle,
display_name: r.connected_display_name,
avatar_url: r.connected_avatar_url,
})
.collect())
}
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = sqlx::query_as(
"SELECT MAX(fetched_at) FROM remote_actor_connections
WHERE actor_url = $1 AND connection_type = $2 AND page = $3",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.fetch_optional(&self.pool)
.await
.into_domain()?;
Ok(row.and_then(|(ts,)| ts))
}
}

View File

@@ -0,0 +1,184 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
tag::Tag,
thought::Thought,
},
ports::TagRepository,
value_objects::ThoughtId,
};
use sqlx::PgPool;
pub struct PgTagRepository {
pool: PgPool,
}
impl PgTagRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TagRepository for PgTagRepository {
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
let name = name.to_lowercase();
sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING")
.bind(&name)
.execute(&self.pool)
.await
.into_domain()?;
#[derive(sqlx::FromRow)]
struct Row {
id: i32,
name: String,
}
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1")
.bind(&name)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Tag {
id: row.id,
name: row.name,
})
}
async fn attach_to_thought(
&self,
thought_id: &ThoughtId,
tag_id: i32,
) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
)
.bind(thought_id.as_uuid())
.bind(tag_id)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1")
.bind(thought_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: i32,
name: String,
}
sqlx::query_as::<_, Row>(
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
.into_domain()
.map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect())
}
async fn list_thoughts_by_tag(
&self,
tag_name: &str,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError> {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1",
)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>(
"SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at
FROM thoughts th JOIN thought_tags tt ON tt.thought_id=th.id JOIN tags t ON t.id=tt.tag_id
WHERE t.name=$1 ORDER BY th.created_at DESC LIMIT $2 OFFSET $3"
).bind(tag_name).bind(page.limit()).bind(page.offset())
.fetch_all(&self.pool).await.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(Thought::try_from)
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
sqlx::query_as::<_, (String, i64)>(
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
FROM tags t
JOIN thought_tags tt ON t.id = tt.tag_id
GROUP BY t.id, t.name
ORDER BY thought_count DESC
LIMIT $1",
)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.into_domain()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};
use domain::{
models::{
thought::{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 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");
}
}

View File

@@ -0,0 +1,37 @@
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
ports::{ThoughtRepository, UserWriter},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
pub 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
}
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,
);
trepo.save(&t).await.unwrap();
(user, t)
}

View File

@@ -0,0 +1,262 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
thought::{Thought, Visibility},
},
ports::ThoughtRepository,
value_objects::{Content, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgThoughtRepository {
pool: PgPool,
}
impl PgThoughtRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct ThoughtRow {
pub id: uuid::Uuid,
pub user_id: uuid::Uuid,
pub content: String,
pub in_reply_to_id: Option<uuid::Uuid>,
pub visibility: String,
pub content_warning: Option<String>,
pub sensitive: bool,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
impl TryFrom<ThoughtRow> for Thought {
type Error = DomainError;
fn try_from(r: ThoughtRow) -> Result<Self, DomainError> {
Ok(Thought {
id: ThoughtId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::from_db_str(&r.visibility)?,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
})
}
}
const THOUGHT_SELECT: &str =
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
#[async_trait]
impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
)
.bind(t.id.as_uuid())
.bind(t.user_id.as_uuid())
.bind(t.content.as_str())
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
.bind(t.visibility.as_str())
.bind(&t.content_warning)
.bind(t.sensitive)
.bind(t.local)
.bind(t.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError> {
sqlx::query_as::<_, ThoughtRow>(&format!("{THOUGHT_SELECT} WHERE id=$1"))
.bind(id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.and_then(|o| o.map(Thought::try_from).transpose())
}
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM thoughts WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> {
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE id=$1")
.bind(id.as_uuid())
.bind(content.as_str())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
// Recursive CTE: fetches the root thought and all nested replies at any depth.
sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id,
visibility,content_warning,sensitive,local,created_at,updated_at
FROM thoughts WHERE id = $1
UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
)
SELECT * FROM thread ORDER BY created_at ASC",
)
.bind(id.as_uuid())
.fetch_all(&self.pool)
.await
.into_domain()
.and_then(|rows| rows.into_iter().map(Thought::try_from).collect())
}
async fn list_by_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError> {
let uid = user_id.as_uuid();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1")
.bind(uid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, ThoughtRow>(&format!(
"{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
))
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(Thought::try_from)
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::seed_user;
use domain::{
models::thought::{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 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_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 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);
}
}

View File

@@ -0,0 +1,155 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{top_friend::TopFriend, user::User},
ports::TopFriendRepository,
value_objects::UserId,
};
use sqlx::PgPool;
pub struct PgTopFriendRepository {
pool: PgPool,
}
impl PgTopFriendRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TopFriendRepository for PgTopFriendRepository {
async fn set_top_friends(
&self,
user_id: &UserId,
friends: Vec<(UserId, i16)>,
) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await.into_domain()?;
sqlx::query("DELETE FROM top_friends WHERE user_id=$1")
.bind(user_id.as_uuid())
.execute(&mut *tx)
.await
.into_domain()?;
for (friend_id, pos) in friends {
sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)")
.bind(user_id.as_uuid())
.bind(friend_id.as_uuid())
.bind(pos)
.execute(&mut *tx)
.await
.into_domain()?;
}
tx.commit().await.into_domain()
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
tf_user_id: uuid::Uuid,
friend_id: uuid::Uuid,
position: i16,
id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
local: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.local,
u.created_at, u.updated_at
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position",
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| {
use domain::value_objects::{Email, PasswordHash, Username};
let tf = TopFriend {
user_id: UserId::from_uuid(r.tf_user_id),
friend_id: UserId::from_uuid(r.friend_id),
position: r.position,
};
let u = User {
id: UserId::from_uuid(r.id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
};
(tf, u)
})
.collect())
}
}
#[cfg(test)]
mod tests {
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
}
#[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");
}
}

View File

@@ -0,0 +1,352 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::feed::{PageParams, Paginated, UserSummary},
models::user::User,
ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username},
};
use sqlx::PgPool;
use std::collections::HashMap;
pub struct PgUserRepository {
pool: PgPool,
}
impl PgUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
pub struct UserRow {
pub id: uuid::Uuid,
pub username: String,
pub email: String,
pub password_hash: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<UserRow> for User {
fn from(r: UserRow) -> Self {
User {
id: UserId::from_uuid(r.id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,local,created_at,updated_at FROM users";
#[async_trait]
impl UserReader for PgUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
.bind(id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(User::from))
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE username=$1"))
.bind(username.as_str())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(User::from))
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE email=$1"))
.bind(email.as_str())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(User::from))
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
display_name: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
thought_count: i64,
follower_count: i64,
following_count: i64,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
COUNT(DISTINCT t.id) AS thought_count,
COUNT(DISTINCT f1.follower_id) AS follower_count,
COUNT(DISTINCT f2.following_id) AS following_count
FROM users u
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true
GROUP BY u.id
ORDER BY u.username",
)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| UserSummary {
id: UserId::from_uuid(r.id),
username: r.username,
display_name: r.display_name,
avatar_url: r.avatar_url,
bio: r.bio,
thought_count: r.thought_count,
follower_count: r.follower_count,
following_count: r.following_count,
})
.collect())
}
async fn count(&self) -> Result<i64, DomainError> {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
.fetch_one(&self.pool)
.await
.into_domain()
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
display_name: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
thought_count: i64,
follower_count: i64,
following_count: i64,
total: i64,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
COUNT(DISTINCT t.id) AS thought_count,
COUNT(DISTINCT f1.follower_id) AS follower_count,
COUNT(DISTINCT f2.following_id) AS following_count,
COUNT(*) OVER() AS total
FROM users u
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true
GROUP BY u.id
ORDER BY u.username
LIMIT $1 OFFSET $2",
)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
let total = rows.first().map(|r| r.total).unwrap_or(0);
let items = rows
.into_iter()
.map(|r| UserSummary {
id: UserId::from_uuid(r.id),
username: r.username,
display_name: r.display_name,
avatar_url: r.avatar_url,
bio: r.bio,
thought_count: r.thought_count,
follower_count: r.follower_count,
following_count: r.following_count,
})
.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> {
if ids.is_empty() {
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()?;
Ok(rows.into_iter().map(|r| {
let user = User::from(r);
(user.id.clone(), user)
}).collect())
}
}
#[async_trait]
impl UserWriter for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
local=EXCLUDED.local,
updated_at=NOW()"
)
.bind(user.id.as_uuid())
.bind(user.username.as_str())
.bind(user.email.as_str())
.bind(&user.password_hash.0)
.bind(&user.display_name)
.bind(&user.bio)
.bind(&user.avatar_url)
.bind(&user.header_url)
.bind(&user.custom_css)
.bind(user.local)
.bind(user.created_at)
.bind(user.updated_at)
.execute(&self.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db) = e {
if db.code().as_deref() == Some("23505") {
return match db.constraint().unwrap_or("") {
"users_username_key" => DomainError::UniqueViolation { field: "username" },
"users_email_key" => DomainError::UniqueViolation { field: "email" },
_ => DomainError::UniqueViolation { field: "unknown" },
};
}
}
DomainError::Internal(e.to_string())
})
.map(|_| ())
}
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>,
) -> 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)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{models::user::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 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,
)
.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"));
}
}