refactor: 5 architectural improvements (Tasks 2-5 + Task 6 fix)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s
- feat(domain): Hashtag value object with canonical extract() — unifies two divergent private implementations; fields pre-compute raw/normalized/url_slug/ap_name - feat(presentation): Deps<S: FromAppState> extractor — each handler now declares its exact dependency surface; AppState unchanged; handlers become unit-testable without mocking all 20 deps - refactor(feed): replace 5 flat FeedRepository methods with FeedQuery/FeedScope — single query() method; SQL shared logic lives once; adding feed types no longer requires 5 edits - refactor(activitypub): ActivityPubRepository + OutboundFederationPort moved out of domain::ports into activitypub-base::ap_ports — domain crate no longer knows about AP IDs, inboxes, or actor URLs - fix(outbox): OutboxRelay now opens a per-row transaction so FOR UPDATE SKIP LOCKED actually holds the lock during publish + mark_delivered
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -289,6 +289,7 @@ dependencies = [
|
|||||||
name = "application"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub-base",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -2447,6 +2448,7 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|||||||
name = "postgres"
|
name = "postgres"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub-base",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -2516,6 +2518,7 @@ dependencies = [
|
|||||||
name = "presentation"
|
name = "presentation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub-base",
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
167
crates/adapters/activitypub-base/src/ap_ports.rs
Normal file
167
crates/adapters/activitypub-base/src/ap_ports.rs
Normal 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<(), 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>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod activities;
|
pub mod activities;
|
||||||
pub mod actor_handler;
|
pub mod actor_handler;
|
||||||
pub mod actors;
|
pub mod actors;
|
||||||
|
pub mod ap_ports;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -17,6 +18,7 @@ pub mod user;
|
|||||||
pub mod webfinger;
|
pub mod webfinger;
|
||||||
|
|
||||||
pub use activitypub_federation::kinds::object::NoteType;
|
pub use activitypub_federation::kinds::object::NoteType;
|
||||||
|
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort};
|
||||||
pub use content::ApObjectHandler;
|
pub use content::ApObjectHandler;
|
||||||
pub use data::FederationData;
|
pub use data::FederationData;
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
|||||||
@@ -50,26 +50,6 @@ fn content_to_html(text: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_hashtag_tags(content: &str, base_url: &str) -> Vec<serde_json::Value> {
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut tags = Vec::new();
|
|
||||||
for word in content.split_whitespace() {
|
|
||||||
let tag = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '#');
|
|
||||||
if let Some(name) = tag.strip_prefix('#')
|
|
||||||
&& !name.is_empty()
|
|
||||||
&& seen.insert(name.to_lowercase())
|
|
||||||
{
|
|
||||||
let lower = name.to_lowercase();
|
|
||||||
tags.push(serde_json::json!({
|
|
||||||
"type": "Hashtag",
|
|
||||||
"name": format!("#{}", lower),
|
|
||||||
"href": format!("{}/tags/{}", base_url, lower),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
|
|
||||||
fn thought_note_json(
|
fn thought_note_json(
|
||||||
thought: &domain::models::thought::Thought,
|
thought: &domain::models::thought::Thought,
|
||||||
local_actor: &crate::actors::DbActor,
|
local_actor: &crate::actors::DbActor,
|
||||||
@@ -114,9 +94,19 @@ fn thought_note_json(
|
|||||||
if let Some(updated_at) = thought.updated_at {
|
if let Some(updated_at) = thought.updated_at {
|
||||||
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
|
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
|
||||||
}
|
}
|
||||||
let hashtag_tags = extract_hashtag_tags(thought.content.as_str(), base_url);
|
let hashtags = domain::hashtag::extract(thought.content.as_str());
|
||||||
if !hashtag_tags.is_empty() {
|
if !hashtags.is_empty() {
|
||||||
note["tag"] = serde_json::json!(hashtag_tags);
|
let ap_tags: Vec<serde_json::Value> = hashtags
|
||||||
|
.iter()
|
||||||
|
.map(|h| {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "Hashtag",
|
||||||
|
"name": h.ap_name,
|
||||||
|
"href": format!("{}/{}", base_url, h.url_slug),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
note["tag"] = serde_json::json!(ap_tags);
|
||||||
}
|
}
|
||||||
Ok((ap_id, note))
|
Ok((ap_id, note))
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1395,7 @@ impl ActivityPubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl domain::ports::OutboundFederationPort for ActivityPubService {
|
impl crate::ap_ports::OutboundFederationPort for ActivityPubService {
|
||||||
// Actor identity (ap_id, followers_url) comes from federation config via get_local_actor.
|
// Actor identity (ap_id, followers_url) comes from federation config via get_local_actor.
|
||||||
// author_username is provided by the caller but not needed here.
|
// author_username is provided by the caller but not needed here.
|
||||||
async fn broadcast_create(
|
async fn broadcast_create(
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::note::ThoughtNote;
|
use crate::note::ThoughtNote;
|
||||||
use crate::urls::ThoughtsUrls;
|
use crate::urls::ThoughtsUrls;
|
||||||
use activitypub_base::ApObjectHandler;
|
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
|
||||||
use domain::ports::{ActivityPubRepository, EventPublisher};
|
use domain::ports::EventPublisher;
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
event-payload = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
ports::{ActivityPubRepository, ActorApUrls, OutboxEntry},
|
|
||||||
value_objects::{Content, ThoughtId, UserId, Username},
|
value_objects::{Content, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use domain::ports::ActivityPubRepository;
|
use activitypub_base::ActivityPubRepository;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ use chrono::{DateTime, Utc};
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
feed::{FeedEntry, Paginated},
|
||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::FeedRepository,
|
ports::{FeedQuery, FeedRepository, FeedScope},
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -150,14 +150,13 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for PgFeedRepository {
|
impl FeedRepository for PgFeedRepository {
|
||||||
async fn home_feed(
|
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
&self,
|
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
|
||||||
following_ids: &[UserId],
|
let page = &q.page;
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
match &q.scope {
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
FeedScope::Home { following_ids } => {
|
||||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let fed_clause = federation_following_clause(viewer);
|
let fed_clause = federation_following_clause(viewer);
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
||||||
@@ -190,12 +189,7 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn public_feed(
|
FeedScope::Public => {
|
||||||
&self,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
||||||
)
|
)
|
||||||
@@ -223,13 +217,7 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search(
|
FeedScope::Search { query } => {
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
||||||
)
|
)
|
||||||
@@ -259,13 +247,7 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tag_feed(
|
FeedScope::Tag { tag_name } => {
|
||||||
&self,
|
|
||||||
tag_name: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
@@ -304,15 +286,8 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_feed(
|
FeedScope::User { user_id } => {
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let uid = user_id.as_uuid();
|
let uid = user_id.as_uuid();
|
||||||
|
|
||||||
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
||||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||||
|
|
||||||
@@ -347,6 +322,8 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -354,10 +331,11 @@ mod tests {
|
|||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
|
feed::PageParams,
|
||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{ThoughtRepository, UserWriter},
|
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,13 +367,10 @@ mod tests {
|
|||||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.public_feed(
|
.query(&FeedQuery::public(
|
||||||
&PageParams {
|
PageParams { page: 1, per_page: 20 },
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
},
|
|
||||||
None,
|
None,
|
||||||
)
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.total, 1);
|
assert_eq!(result.total, 1);
|
||||||
@@ -408,14 +383,11 @@ mod tests {
|
|||||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.search(
|
.query(&FeedQuery::search(
|
||||||
"hello world",
|
"hello world",
|
||||||
&PageParams {
|
PageParams { page: 1, per_page: 20 },
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
},
|
|
||||||
None,
|
None,
|
||||||
)
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.total >= 1);
|
assert!(result.total >= 1);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod use_cases;
|
pub mod use_cases;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod testing;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::Visibility,
|
models::thought::Visibility,
|
||||||
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader},
|
ports::{ThoughtRepository, UserReader},
|
||||||
value_objects::ThoughtId,
|
value_objects::ThoughtId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -212,13 +213,14 @@ impl FederationEventService {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use activitypub_base::{ActorApUrls, OutboundFederationPort};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use crate::testing::TestApRepo;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
models::user::User,
|
models::user::User,
|
||||||
ports::{ActivityPubRepository, OutboundFederationPort},
|
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
@@ -325,12 +327,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||||
|
let ap_repo = TestApRepo::new(store.clone());
|
||||||
FederationEventService {
|
FederationEventService {
|
||||||
thoughts: Arc::new(store.clone()),
|
thoughts: Arc::new(store.clone()),
|
||||||
users: Arc::new(store.clone()),
|
users: Arc::new(store.clone()),
|
||||||
ap: spy,
|
ap: spy,
|
||||||
base_url: "https://example.com".to_string(),
|
base_url: "https://example.com".to_string(),
|
||||||
ap_repo: Arc::new(store.clone()),
|
ap_repo: Arc::new(ap_repo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
|
||||||
|
FederationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
users: Arc::new(store.clone()),
|
||||||
|
ap: spy,
|
||||||
|
base_url: "https://example.com".to_string(),
|
||||||
|
ap_repo: Arc::new(ap_repo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +465,8 @@ mod tests {
|
|||||||
let alice = alice();
|
let alice = alice();
|
||||||
let mut thought = local_thought(alice.id.clone());
|
let mut thought = local_thought(alice.id.clone());
|
||||||
thought.local = false;
|
thought.local = false;
|
||||||
store.thought_ap_ids.lock().unwrap().insert(
|
let ap_repo = TestApRepo::new(store.clone());
|
||||||
|
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||||
thought.id.clone(),
|
thought.id.clone(),
|
||||||
"https://mastodon.social/users/bob/statuses/123".into(),
|
"https://mastodon.social/users/bob/statuses/123".into(),
|
||||||
);
|
);
|
||||||
@@ -460,7 +474,7 @@ mod tests {
|
|||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
let spy = Arc::new(SpyPort::default());
|
||||||
svc(&store, spy.clone())
|
svc_with_ap(&store, ap_repo, spy.clone())
|
||||||
.process(&DomainEvent::BoostAdded {
|
.process(&DomainEvent::BoostAdded {
|
||||||
boost_id: BoostId::new(),
|
boost_id: BoostId::new(),
|
||||||
user_id: alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
@@ -604,14 +618,15 @@ mod tests {
|
|||||||
let alice = alice();
|
let alice = alice();
|
||||||
let mut thought = local_thought(alice.id.clone());
|
let mut thought = local_thought(alice.id.clone());
|
||||||
thought.local = false;
|
thought.local = false;
|
||||||
store.thought_ap_ids.lock().unwrap().insert(
|
let ap_repo = TestApRepo::new(store.clone());
|
||||||
|
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||||
thought.id.clone(),
|
thought.id.clone(),
|
||||||
"https://mastodon.social/users/bob/statuses/456".into(),
|
"https://mastodon.social/users/bob/statuses/456".into(),
|
||||||
);
|
);
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
let spy = Arc::new(SpyPort::default());
|
||||||
svc(&store, spy.clone())
|
svc_with_ap(&store, ap_repo, spy.clone())
|
||||||
.process(&DomainEvent::BoostRemoved {
|
.process(&DomainEvent::BoostRemoved {
|
||||||
user_id: alice.id.clone(),
|
user_id: alice.id.clone(),
|
||||||
thought_id: thought.id.clone(),
|
thought_id: thought.id.clone(),
|
||||||
@@ -673,28 +688,28 @@ mod tests {
|
|||||||
PasswordHash("h".into()),
|
PasswordHash("h".into()),
|
||||||
);
|
);
|
||||||
author.local = false;
|
author.local = false;
|
||||||
store.actor_ap_urls.lock().unwrap().insert(
|
|
||||||
author.id.clone(),
|
|
||||||
domain::ports::ActorApUrls {
|
|
||||||
ap_id: "https://mastodon.social/users/author".into(),
|
|
||||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let thought = local_thought(author.id.clone());
|
let thought = local_thought(author.id.clone());
|
||||||
store.thought_ap_ids.lock().unwrap().insert(
|
|
||||||
thought.id.clone(),
|
|
||||||
"https://mastodon.social/posts/123".into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let liker = alice();
|
let liker = alice();
|
||||||
|
|
||||||
store.users.lock().unwrap().push(author.clone());
|
store.users.lock().unwrap().push(author.clone());
|
||||||
store.users.lock().unwrap().push(liker.clone());
|
store.users.lock().unwrap().push(liker.clone());
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let ap_repo = TestApRepo::new(store.clone());
|
||||||
|
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||||
|
author.id.clone(),
|
||||||
|
ActorApUrls {
|
||||||
|
ap_id: "https://mastodon.social/users/author".into(),
|
||||||
|
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||||
|
thought.id.clone(),
|
||||||
|
"https://mastodon.social/posts/123".into(),
|
||||||
|
);
|
||||||
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
let spy = Arc::new(SpyPort::default());
|
||||||
svc(&store, spy.clone())
|
svc_with_ap(&store, ap_repo, spy.clone())
|
||||||
.process(&DomainEvent::LikeAdded {
|
.process(&DomainEvent::LikeAdded {
|
||||||
like_id: LikeId::new(),
|
like_id: LikeId::new(),
|
||||||
user_id: liker.id,
|
user_id: liker.id,
|
||||||
|
|||||||
150
crates/application/src/testing.rs
Normal file
150
crates/application/src/testing.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/// Test helpers for application-layer tests that need activitypub_base traits.
|
||||||
|
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::user::User,
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Extends `TestStore` with AP-specific lookup maps.
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct TestApRepo {
|
||||||
|
pub inner: TestStore,
|
||||||
|
/// UserId → ActorApUrls (for get_actor_ap_urls)
|
||||||
|
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestApRepo {
|
||||||
|
pub fn new(inner: TestStore) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
actor_ap_urls: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityPubRepository for TestApRepo {
|
||||||
|
async fn outbox_entries_for_actor(
|
||||||
|
&self,
|
||||||
|
_uid: &UserId,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn outbox_page_for_actor(
|
||||||
|
&self,
|
||||||
|
_uid: &UserId,
|
||||||
|
_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
_limit: usize,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn find_remote_actor_id(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
) -> Result<Option<UserId>, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.actor_ap_ids
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(actor_ap_url)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||||
|
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||||
|
return Ok(uid);
|
||||||
|
}
|
||||||
|
let uid = UserId::new();
|
||||||
|
let handle = url::Url::parse(actor_ap_url)
|
||||||
|
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
||||||
|
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
||||||
|
let user = User {
|
||||||
|
id: uid.clone(),
|
||||||
|
username: Username::from_trusted(handle),
|
||||||
|
email: Email::from_trusted(format!("{}@remote", uid)),
|
||||||
|
password_hash: PasswordHash("".into()),
|
||||||
|
display_name: None,
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
header_url: None,
|
||||||
|
custom_css: None,
|
||||||
|
local: false,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
self.inner.users.lock().unwrap().push(user);
|
||||||
|
self.inner
|
||||||
|
.actor_ap_ids
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(actor_ap_url.to_string(), uid.clone());
|
||||||
|
Ok(uid)
|
||||||
|
}
|
||||||
|
async fn update_remote_actor_display(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_display_name: Option<&str>,
|
||||||
|
_avatar_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
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<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn apply_note_update(
|
||||||
|
&self,
|
||||||
|
_ap_id: &str,
|
||||||
|
_new_content: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.thoughts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.local)
|
||||||
|
.count() as u64)
|
||||||
|
}
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.thought_ap_ids
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(thought_id)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
async fn get_actor_ap_urls(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||||
|
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,13 +38,11 @@ pub async fn register(
|
|||||||
.save(&user)
|
.save(&user)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
DomainError::Conflict(ref c) if c.contains("username") => {
|
DomainError::Conflict(c) => match c.as_str() {
|
||||||
DomainError::Conflict("username taken".into())
|
"users_username_key" => DomainError::Conflict("username taken".into()),
|
||||||
}
|
"users_email_key" => DomainError::Conflict("email taken".into()),
|
||||||
DomainError::Conflict(ref c) if c.contains("email") => {
|
_ => DomainError::Conflict("already exists".into()),
|
||||||
DomainError::Conflict("email taken".into())
|
},
|
||||||
}
|
|
||||||
DomainError::Conflict(_) => DomainError::Conflict("already exists".into()),
|
|
||||||
other => other,
|
other => other,
|
||||||
})?;
|
})?;
|
||||||
events
|
events
|
||||||
@@ -111,6 +109,7 @@ mod tests {
|
|||||||
/// Simulates a concurrent registration that slips past the pre-checks and
|
/// Simulates a concurrent registration that slips past the pre-checks and
|
||||||
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
|
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
|
||||||
struct ConflictOnSaveStore(TestStore);
|
struct ConflictOnSaveStore(TestStore);
|
||||||
|
struct EmailConflictOnSaveStore(TestStore);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserReader for ConflictOnSaveStore {
|
impl UserReader for ConflictOnSaveStore {
|
||||||
@@ -154,6 +153,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserReader for EmailConflictOnSaveStore {
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
|
self.0.find_by_id(id).await
|
||||||
|
}
|
||||||
|
async fn find_by_username(
|
||||||
|
&self,
|
||||||
|
username: &Username,
|
||||||
|
) -> Result<Option<User>, DomainError> {
|
||||||
|
self.0.find_by_username(username).await
|
||||||
|
}
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
self.0.find_by_email(email).await
|
||||||
|
}
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
self.0.list_with_stats().await
|
||||||
|
}
|
||||||
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
|
self.0.count().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserWriter for EmailConflictOnSaveStore {
|
||||||
|
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||||
|
Err(DomainError::Conflict("users_email_key".into()))
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
self.0
|
||||||
|
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct FakeHasher;
|
struct FakeHasher;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PasswordHasher for FakeHasher {
|
impl PasswordHasher for FakeHasher {
|
||||||
@@ -315,4 +356,17 @@ mod tests {
|
|||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_maps_db_conflict_on_email_to_conflict() {
|
||||||
|
let store = EmailConflictOnSaveStore(TestStore::default());
|
||||||
|
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, DomainError::Conflict(ref m) if m == "email taken"),
|
||||||
|
"expected 'email taken', got: {:?}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use activitypub_base::ActivityPubRepository;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
@@ -6,9 +7,9 @@ use domain::{
|
|||||||
remote_actor::RemoteActor,
|
remote_actor::RemoteActor,
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
ActivityPubRepository, EventPublisher, FederationActionPort, FederationFollowPort,
|
EventPublisher, FederationActionPort, FederationFollowPort,
|
||||||
FederationFollowRequestPort, FederationSchedulerPort, FeedRepository, FollowRepository,
|
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
|
||||||
RemoteActorConnectionRepository, UserReader,
|
FollowRepository, RemoteActorConnectionRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
@@ -85,7 +86,7 @@ pub async fn get_remote_actor_posts(
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||||
};
|
};
|
||||||
let result = feed.user_feed(&author_id, &page, viewer_id).await?;
|
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
|
||||||
if let Some(outbox_url) = actor.outbox_url {
|
if let Some(outbox_url) = actor.outbox_url {
|
||||||
let _ = scheduler
|
let _ = scheduler
|
||||||
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedRepository, FollowRepository, TagRepository, UserReader},
|
ports::{FeedQuery, FeedRepository, FollowRepository, TagRepository, UserReader},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ pub async fn get_home_feed(
|
|||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||||
following_ids.push(user_id.clone()); // include own thoughts in home feed
|
following_ids.push(user_id.clone()); // include own thoughts in home feed
|
||||||
feed.home_feed(&following_ids, &page, Some(user_id)).await
|
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_public_feed(
|
pub async fn get_public_feed(
|
||||||
@@ -24,7 +24,7 @@ pub async fn get_public_feed(
|
|||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
page: PageParams,
|
page: PageParams,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
feed.public_feed(&page, viewer_id).await
|
feed.query(&FeedQuery::public(page, viewer_id.cloned())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_feed(
|
pub async fn get_user_feed(
|
||||||
@@ -33,7 +33,7 @@ pub async fn get_user_feed(
|
|||||||
page: PageParams,
|
page: PageParams,
|
||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
feed.user_feed(user_id, &page, viewer_id).await
|
feed.query(&FeedQuery::user(user_id.clone(), page, viewer_id.cloned())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followers(
|
pub async fn get_followers(
|
||||||
@@ -58,7 +58,7 @@ pub async fn get_by_tag(
|
|||||||
page: PageParams,
|
page: PageParams,
|
||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
feed.tag_feed(tag_name, &page, viewer_id).await
|
feed.query(&FeedQuery::tag(tag_name, page, viewer_id.cloned())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
@@ -67,7 +67,7 @@ pub async fn search(
|
|||||||
page: PageParams,
|
page: PageParams,
|
||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
feed.search(query, &page, viewer_id).await
|
feed.query(&FeedQuery::search(query, page, viewer_id.cloned())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
|
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
|||||||
@@ -6,30 +6,6 @@ use domain::{
|
|||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn extract_hashtags(content: &str) -> Vec<String> {
|
|
||||||
let mut tags = Vec::new();
|
|
||||||
let mut chars = content.char_indices().peekable();
|
|
||||||
while let Some((_, c)) = chars.next() {
|
|
||||||
if c == '#'
|
|
||||||
&& chars
|
|
||||||
.peek()
|
|
||||||
.map(|(_, nc)| nc.is_alphanumeric())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
let tag: String = chars
|
|
||||||
.by_ref()
|
|
||||||
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
|
||||||
.map(|(_, nc)| nc)
|
|
||||||
.collect();
|
|
||||||
if !tag.is_empty() {
|
|
||||||
tags.push(tag.to_lowercase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tags.dedup();
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
if thought.user_id != *user_id {
|
if thought.user_id != *user_id {
|
||||||
return Err(DomainError::NotFound);
|
return Err(DomainError::NotFound);
|
||||||
@@ -76,8 +52,8 @@ pub async fn create_thought(
|
|||||||
thoughts.save(&thought).await?;
|
thoughts.save(&thought).await?;
|
||||||
|
|
||||||
// Extract and attach hashtags from content.
|
// Extract and attach hashtags from content.
|
||||||
for tag_name in extract_hashtags(content.as_str()) {
|
for h in domain::hashtag::extract(content.as_str()) {
|
||||||
if let Ok(tag) = tags.find_or_create(&tag_name).await {
|
if let Ok(tag) = tags.find_or_create(&h.normalized).await {
|
||||||
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +171,33 @@ mod tests {
|
|||||||
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
|
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_thought_stages_outbox_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let outbox = TestOutbox::default();
|
||||||
|
let u = user();
|
||||||
|
store.users.lock().unwrap().push(u.clone());
|
||||||
|
let out = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
&NoOpOutboxWriter,
|
||||||
|
input(u.id.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let tid = out.thought.id.clone();
|
||||||
|
|
||||||
|
delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let staged = outbox.staged();
|
||||||
|
assert_eq!(staged.len(), 1);
|
||||||
|
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_own_thought_succeeds() {
|
async fn delete_own_thought_succeeds() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
|
|||||||
139
crates/domain/src/hashtag.rs
Normal file
139
crates/domain/src/hashtag.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// A hashtag extracted from content.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Hashtag {
|
||||||
|
/// Original casing, e.g. "Rust"
|
||||||
|
pub raw: String,
|
||||||
|
/// Lowercased, e.g. "rust" — used for DB lookups
|
||||||
|
pub normalized: String,
|
||||||
|
/// "tags/rust" — callers prepend base_url
|
||||||
|
pub url_slug: String,
|
||||||
|
/// "#rust" — used directly in AP tag array
|
||||||
|
pub ap_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract hashtags from content using a char-by-char scan.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - Tag starts after a bare `#` followed immediately by an alphanumeric char.
|
||||||
|
/// - Tag chars: `[A-Za-z0-9_]`.
|
||||||
|
/// - Deduplicated case-insensitively; first occurrence wins.
|
||||||
|
/// - Returned in order of first appearance.
|
||||||
|
pub fn extract(content: &str) -> Vec<Hashtag> {
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut tags: Vec<Hashtag> = Vec::new();
|
||||||
|
let mut chars = content.char_indices().peekable();
|
||||||
|
|
||||||
|
while let Some((_, c)) = chars.next() {
|
||||||
|
if c == '#'
|
||||||
|
&& chars
|
||||||
|
.peek()
|
||||||
|
.map(|(_, nc)| nc.is_alphanumeric())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let raw: String = chars
|
||||||
|
.by_ref()
|
||||||
|
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
||||||
|
.map(|(_, nc)| nc)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if raw.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = raw.to_lowercase();
|
||||||
|
if seen.insert(normalized.clone()) {
|
||||||
|
tags.push(Hashtag {
|
||||||
|
url_slug: format!("tags/{}", normalized),
|
||||||
|
ap_name: format!("#{}", normalized),
|
||||||
|
raw,
|
||||||
|
normalized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn names(tags: &[Hashtag]) -> Vec<&str> {
|
||||||
|
tags.iter().map(|h| h.normalized.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic() {
|
||||||
|
let tags = extract("Hello #world and #Rust!");
|
||||||
|
assert_eq!(names(&tags), ["world", "rust"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fields() {
|
||||||
|
let tags = extract("#Rust");
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
let h = &tags[0];
|
||||||
|
assert_eq!(h.raw, "Rust");
|
||||||
|
assert_eq!(h.normalized, "rust");
|
||||||
|
assert_eq!(h.url_slug, "tags/rust");
|
||||||
|
assert_eq!(h.ap_name, "#rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_case_insensitive() {
|
||||||
|
let tags = extract("#rust #Rust #RUST");
|
||||||
|
assert_eq!(names(&tags), ["rust"]);
|
||||||
|
assert_eq!(tags[0].raw, "rust"); // first occurrence wins
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_non_adjacent() {
|
||||||
|
// The old algorithm used Vec::dedup() which only removes adjacent duplicates.
|
||||||
|
// Using HashSet silently fixed this bug. This test documents the fix.
|
||||||
|
let tags = extract("#a #b #a");
|
||||||
|
assert_eq!(tags.len(), 2);
|
||||||
|
assert_eq!(tags[0].normalized, "a");
|
||||||
|
assert_eq!(tags[1].normalized, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mid_word_extracted() {
|
||||||
|
// `text#tag` — `#` not preceded by whitespace is still matched by the
|
||||||
|
// char-by-char scan (the old algorithm didn't require whitespace before `#`).
|
||||||
|
// This test documents the authoritative behaviour: mid-word tags ARE extracted.
|
||||||
|
let tags = extract("text#tag");
|
||||||
|
assert_eq!(names(&tags), ["tag"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_only_ignored() {
|
||||||
|
assert!(extract("# lone hash").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_punctuation_excluded() {
|
||||||
|
// punctuation after tag terminates the tag, not included
|
||||||
|
let tags = extract("#rust.");
|
||||||
|
assert_eq!(names(&tags), ["rust"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn underscore_allowed() {
|
||||||
|
let tags = extract("#hello_world");
|
||||||
|
assert_eq!(names(&tags), ["hello_world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_content() {
|
||||||
|
assert!(extract("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_of_appearance() {
|
||||||
|
let tags = extract("#b #a #c");
|
||||||
|
assert_eq!(names(&tags), ["b", "a", "c"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod hashtag;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod value_objects;
|
pub mod value_objects;
|
||||||
|
|||||||
@@ -317,37 +317,43 @@ impl<
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum FeedScope {
|
||||||
|
Home { following_ids: Vec<UserId> },
|
||||||
|
Public,
|
||||||
|
Tag { tag_name: String },
|
||||||
|
User { user_id: UserId },
|
||||||
|
Search { query: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FeedQuery {
|
||||||
|
pub scope: FeedScope,
|
||||||
|
pub page: PageParams,
|
||||||
|
pub viewer_id: Option<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedQuery {
|
||||||
|
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self {
|
||||||
|
Self { scope: FeedScope::Home { following_ids }, page, viewer_id: Some(viewer_id) }
|
||||||
|
}
|
||||||
|
pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
|
Self { scope: FeedScope::Public, page, viewer_id }
|
||||||
|
}
|
||||||
|
pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
|
Self { scope: FeedScope::Tag { tag_name: tag_name.into() }, page, viewer_id }
|
||||||
|
}
|
||||||
|
pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
|
Self { scope: FeedScope::User { user_id }, page, viewer_id }
|
||||||
|
}
|
||||||
|
pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
|
||||||
|
Self { scope: FeedScope::Search { query: query.into() }, page, viewer_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FeedRepository: Send + Sync {
|
pub trait FeedRepository: Send + Sync {
|
||||||
async fn home_feed(
|
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
&self,
|
|
||||||
following_ids: &[UserId],
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn public_feed(
|
|
||||||
&self,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn search(
|
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn tag_feed(
|
|
||||||
&self,
|
|
||||||
tag_name: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn user_feed(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -368,166 +374,6 @@ pub trait SearchPort: Send + Sync {
|
|||||||
) -> Result<Paginated<User>, DomainError>;
|
) -> Result<Paginated<User>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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: crate::models::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<(), 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FederationSchedulerPort: Send + Sync {
|
pub trait FederationSchedulerPort: Send + Sync {
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ pub struct TestStore {
|
|||||||
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
||||||
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
||||||
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
||||||
/// UserId → ActorApUrls (used by get_actor_ap_urls)
|
|
||||||
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -706,63 +704,7 @@ impl RemoteActorConnectionRepository for TestStore {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for TestStore {
|
impl FeedRepository for TestStore {
|
||||||
async fn home_feed(
|
async fn query(&self, _q: &crate::ports::FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
&self,
|
|
||||||
_ids: &[UserId],
|
|
||||||
_p: &PageParams,
|
|
||||||
_v: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
Ok(Paginated {
|
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async fn public_feed(
|
|
||||||
&self,
|
|
||||||
_p: &PageParams,
|
|
||||||
_v: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
Ok(Paginated {
|
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async fn search(
|
|
||||||
&self,
|
|
||||||
_q: &str,
|
|
||||||
_p: &PageParams,
|
|
||||||
_v: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
Ok(Paginated {
|
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async fn tag_feed(
|
|
||||||
&self,
|
|
||||||
_tag_name: &str,
|
|
||||||
_page: &PageParams,
|
|
||||||
_viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
Ok(Paginated {
|
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async fn user_feed(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_page: &PageParams,
|
|
||||||
_viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: vec![],
|
items: vec![],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -801,109 +743,6 @@ impl SearchPort for TestStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ActivityPubRepository for TestStore {
|
|
||||||
async fn outbox_entries_for_actor(
|
|
||||||
&self,
|
|
||||||
_uid: &UserId,
|
|
||||||
) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
async fn outbox_page_for_actor(
|
|
||||||
&self,
|
|
||||||
_uid: &UserId,
|
|
||||||
_before: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
_limit: usize,
|
|
||||||
) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
async fn find_remote_actor_id(
|
|
||||||
&self,
|
|
||||||
actor_ap_url: &str,
|
|
||||||
) -> Result<Option<UserId>, DomainError> {
|
|
||||||
Ok(self.actor_ap_ids.lock().unwrap().get(actor_ap_url).cloned())
|
|
||||||
}
|
|
||||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
|
||||||
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
|
||||||
return Ok(uid);
|
|
||||||
}
|
|
||||||
let uid = UserId::new();
|
|
||||||
let handle = url::Url::parse(actor_ap_url)
|
|
||||||
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
|
||||||
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
|
||||||
let user = crate::models::user::User {
|
|
||||||
id: uid.clone(),
|
|
||||||
username: Username::from_trusted(handle.clone()),
|
|
||||||
email: Email::from_trusted(format!("{}@remote", uid)),
|
|
||||||
password_hash: PasswordHash("".into()),
|
|
||||||
display_name: None,
|
|
||||||
bio: None,
|
|
||||||
avatar_url: None,
|
|
||||||
header_url: None,
|
|
||||||
custom_css: None,
|
|
||||||
local: false,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
updated_at: chrono::Utc::now(),
|
|
||||||
};
|
|
||||||
self.users.lock().unwrap().push(user);
|
|
||||||
self.actor_ap_ids
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(actor_ap_url.to_string(), uid.clone());
|
|
||||||
Ok(uid)
|
|
||||||
}
|
|
||||||
async fn update_remote_actor_display(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_display_name: Option<&str>,
|
|
||||||
_avatar_url: Option<&str>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
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<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
|
||||||
Ok(self
|
|
||||||
.thoughts
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|t| t.local)
|
|
||||||
.count() as u64)
|
|
||||||
}
|
|
||||||
async fn get_thought_ap_id(
|
|
||||||
&self,
|
|
||||||
thought_id: &ThoughtId,
|
|
||||||
) -> Result<Option<String>, DomainError> {
|
|
||||||
Ok(self.thought_ap_ids.lock().unwrap().get(thought_id).cloned())
|
|
||||||
}
|
|
||||||
async fn get_actor_ap_urls(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
|
||||||
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationSchedulerPort for TestStore {
|
impl FederationSchedulerPort for TestStore {
|
||||||
@@ -964,31 +803,6 @@ impl OutboxWriter for NoOpOutboxWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod ap_repo_tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::value_objects::UserId;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_store_outbox_returns_empty() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let result = store
|
|
||||||
.outbox_entries_for_actor(&UserId::new())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_store_intern_creates_placeholder() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let url = "https://example.com/users/alice";
|
|
||||||
let id1 = store.intern_remote_actor(url).await.unwrap();
|
|
||||||
let id2 = store.intern_remote_actor(url).await.unwrap();
|
|
||||||
assert_eq!(id1, id2, "intern must be idempotent");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod federation_port_tests {
|
mod federation_port_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
api-types = { workspace = true }
|
api-types = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ use crate::{errors::ApiError, state::AppState};
|
|||||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deps<S> extractor — narrows AppState to a handler-specific deps struct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Deps<S>(pub S);
|
||||||
|
|
||||||
|
pub trait FromAppState: Sized {
|
||||||
|
fn from_state(s: &AppState) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: FromAppState + Send + 'static> FromRequestParts<AppState> for Deps<S> {
|
||||||
|
type Rejection = std::convert::Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
_parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
Ok(Deps(S::from_state(state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AuthUser(pub UserId);
|
pub struct AuthUser(pub UserId);
|
||||||
pub struct OptionalAuthUser(pub Option<UserId>);
|
pub struct OptionalAuthUser(pub Option<UserId>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::{AuthUser, Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::CreateApiKeyRequest,
|
requests::CreateApiKeyRequest,
|
||||||
responses::{ApiKeyResponse, CreatedApiKeyResponse},
|
responses::{ApiKeyResponse, CreatedApiKeyResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::value_objects::ApiKeyId;
|
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct ApiKeysDeps {
|
||||||
|
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for ApiKeysDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
api_keys: s.api_keys.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec<ApiKeyResponse>)), security(("bearer_auth" = [])))]
|
#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec<ApiKeyResponse>)), security(("bearer_auth" = [])))]
|
||||||
pub async fn get_api_keys(
|
pub async fn get_api_keys(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ApiKeysDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
|
) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
|
||||||
let keys = list_api_keys(&*s.api_keys, &uid).await?;
|
let keys = list_api_keys(&*d.api_keys, &uid).await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
keys.into_iter()
|
keys.into_iter()
|
||||||
.map(|k| ApiKeyResponse {
|
.map(|k| ApiKeyResponse {
|
||||||
@@ -30,21 +47,21 @@ pub async fn get_api_keys(
|
|||||||
}
|
}
|
||||||
#[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_api_key(
|
pub async fn post_api_key(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ApiKeysDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<CreateApiKeyRequest>,
|
Json(body): Json<CreateApiKeyRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await?;
|
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }),
|
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
|
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_api_key_handler(
|
pub async fn delete_api_key_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ApiKeysDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
|
delete_api_key(&*d.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::{Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{LoginRequest, RegisterRequest},
|
requests::{LoginRequest, RegisterRequest},
|
||||||
responses::{AuthResponse, ErrorResponse, UserResponse},
|
responses::{AuthResponse, ErrorResponse, UserResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub auth: Arc<dyn AuthService>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for AuthDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
users: s.users.clone(),
|
||||||
|
hasher: s.hasher.clone(),
|
||||||
|
auth: s.auth.clone(),
|
||||||
|
events: s.events.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
||||||
UserResponse {
|
UserResponse {
|
||||||
@@ -31,14 +55,14 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn post_register(
|
pub async fn post_register(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<AuthDeps>,
|
||||||
Json(body): Json<RegisterRequest>,
|
Json(body): Json<RegisterRequest>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let out = register(
|
let out = register(
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.hasher,
|
&*d.hasher,
|
||||||
&*s.auth,
|
&*d.auth,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
RegisterInput {
|
RegisterInput {
|
||||||
username: body.username,
|
username: body.username,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@@ -62,13 +86,13 @@ pub async fn post_register(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn post_login(
|
pub async fn post_login(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<AuthDeps>,
|
||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let out = login(
|
let out = login(
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.hasher,
|
&*d.hasher,
|
||||||
&*s.auth,
|
&*d.auth,
|
||||||
LoginInput {
|
LoginInput {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
password: body.password,
|
password: body.password,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response,
|
errors::ApiError,
|
||||||
|
extractors::{Deps, FromAppState, OptionalAuthUser},
|
||||||
|
handlers::feed::to_thought_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
@@ -10,13 +12,41 @@ use application::use_cases::federation_management::{
|
|||||||
get_actor_connections_page, get_remote_actor_posts,
|
get_actor_connections_page, get_remote_actor_posts,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::models::feed::PageParams;
|
use activitypub_base::ActivityPubRepository;
|
||||||
|
use domain::{
|
||||||
|
models::feed::PageParams,
|
||||||
|
ports::{
|
||||||
|
FederationActionPort, FederationSchedulerPort, FeedRepository,
|
||||||
|
RemoteActorConnectionRepository,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct FederationActorsDeps {
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
|
pub feed: Arc<dyn FeedRepository>,
|
||||||
|
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||||
|
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for FederationActorsDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
federation: s.federation.clone(),
|
||||||
|
ap_repo: s.ap_repo.clone(),
|
||||||
|
feed: s.feed.clone(),
|
||||||
|
federation_scheduler: s.federation_scheduler.clone(),
|
||||||
|
remote_actor_connections: s.remote_actor_connections.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remote_actor_posts_handler(
|
pub async fn remote_actor_posts_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
@@ -26,10 +56,10 @@ pub async fn remote_actor_posts_handler(
|
|||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_remote_actor_posts(
|
let result = get_remote_actor_posts(
|
||||||
&*s.federation,
|
&*d.federation,
|
||||||
&*s.ap_repo,
|
&*d.ap_repo,
|
||||||
&*s.feed,
|
&*d.feed,
|
||||||
&*s.federation_scheduler,
|
&*d.federation_scheduler,
|
||||||
&handle,
|
&handle,
|
||||||
page,
|
page,
|
||||||
viewer.as_ref(),
|
viewer.as_ref(),
|
||||||
@@ -44,31 +74,31 @@ pub async fn remote_actor_posts_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn actor_followers_handler(
|
pub async fn actor_followers_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||||
actor_connections_handler(s, handle, "followers", q.page() as u32).await
|
actor_connections_handler(d, handle, "followers", q.page() as u32).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn actor_following_handler(
|
pub async fn actor_following_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||||
actor_connections_handler(s, handle, "following", q.page() as u32).await
|
actor_connections_handler(d, handle, "following", q.page() as u32).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor_connections_handler(
|
async fn actor_connections_handler(
|
||||||
s: AppState,
|
d: FederationActorsDeps,
|
||||||
handle: String,
|
handle: String,
|
||||||
connection_type: &str,
|
connection_type: &str,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||||
let (items, has_more) = get_actor_connections_page(
|
let (items, has_more) = get_actor_connections_page(
|
||||||
&*s.federation,
|
&*d.federation,
|
||||||
&*s.remote_actor_connections,
|
&*d.remote_actor_connections,
|
||||||
&*s.federation_scheduler,
|
&*d.federation_scheduler,
|
||||||
&handle,
|
&handle,
|
||||||
connection_type,
|
connection_type,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::{AuthUser, Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use api_types::responses::{ProfileField, RemoteActorResponse};
|
use api_types::responses::{ProfileField, RemoteActorResponse};
|
||||||
use application::use_cases::federation_management::{
|
use application::use_cases::federation_management::{
|
||||||
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following,
|
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following,
|
||||||
reject_follow_request, remove_remote_following,
|
reject_follow_request, remove_remote_following,
|
||||||
};
|
};
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{http::StatusCode, Json};
|
||||||
|
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ActorUrlBody {
|
pub struct ActorUrlBody {
|
||||||
@@ -17,6 +23,24 @@ pub struct HandleBody {
|
|||||||
pub handle: String,
|
pub handle: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FederationManagementDeps {
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for FederationManagementDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
federation: s.federation.clone(),
|
||||||
|
follows: s.follows.clone(),
|
||||||
|
users: s.users.clone(),
|
||||||
|
events: s.events.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse {
|
fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse {
|
||||||
RemoteActorResponse {
|
RemoteActorResponse {
|
||||||
handle: a.handle,
|
handle: a.handle,
|
||||||
@@ -38,57 +62,57 @@ fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorRespo
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_pending_requests(
|
pub async fn get_pending_requests(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
||||||
let actors = list_pending_requests(&*s.federation, &uid).await?;
|
let actors = list_pending_requests(&*d.federation, &uid).await?;
|
||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_accept_request(
|
pub async fn post_accept_request(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<ActorUrlBody>,
|
Json(body): Json<ActorUrlBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
accept_follow_request(&*s.federation, &uid, &body.actor_url).await?;
|
accept_follow_request(&*d.federation, &uid, &body.actor_url).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_follower(
|
pub async fn delete_follower(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<ActorUrlBody>,
|
Json(body): Json<ActorUrlBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
reject_follow_request(&*s.federation, &uid, &body.actor_url).await?;
|
reject_follow_request(&*d.federation, &uid, &body.actor_url).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_remote_followers(
|
pub async fn get_remote_followers(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
||||||
let actors = list_remote_followers(&*s.federation, &uid).await?;
|
let actors = list_remote_followers(&*d.federation, &uid).await?;
|
||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_remote_following(
|
pub async fn get_remote_following(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
||||||
let actors = list_remote_following(&*s.federation, &uid).await?;
|
let actors = list_remote_following(&*d.federation, &uid).await?;
|
||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_following(
|
pub async fn delete_following(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<HandleBody>,
|
Json(body): Json<HandleBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
remove_remote_following(
|
remove_remote_following(
|
||||||
&*s.follows,
|
&*d.follows,
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.federation,
|
&*d.federation,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&uid,
|
&uid,
|
||||||
&body.handle,
|
&body.handle,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, OptionalAuthUser},
|
extractors::{Deps, FromAppState, OptionalAuthUser, AuthUser},
|
||||||
handlers::auth::to_user_response,
|
handlers::auth::to_user_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -12,12 +12,38 @@ use application::use_cases::feed::{
|
|||||||
};
|
};
|
||||||
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::models::feed::PageParams;
|
use domain::{
|
||||||
|
models::feed::PageParams,
|
||||||
|
ports::{FederationActionPort, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct FeedDeps {
|
||||||
|
pub feed: Arc<dyn FeedRepository>,
|
||||||
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
|
pub search: Arc<dyn SearchPort>,
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub tags: Arc<dyn TagRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for FeedDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
feed: s.feed.clone(),
|
||||||
|
follows: s.follows.clone(),
|
||||||
|
search: s.search.clone(),
|
||||||
|
federation: s.federation.clone(),
|
||||||
|
users: s.users.clone(),
|
||||||
|
tags: s.tags.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||||
ThoughtResponse {
|
ThoughtResponse {
|
||||||
@@ -46,7 +72,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn home_feed(
|
pub async fn home_feed(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -54,7 +80,7 @@ pub async fn home_feed(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
|
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
@@ -69,7 +95,7 @@ pub async fn home_feed(
|
|||||||
responses((status = 200, description = "Public feed"))
|
responses((status = 200, description = "Public feed"))
|
||||||
)]
|
)]
|
||||||
pub async fn public_feed(
|
pub async fn public_feed(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -77,7 +103,7 @@ pub async fn public_feed(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?;
|
let result = get_public_feed(&*d.feed, viewer.as_ref(), page).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
@@ -92,7 +118,7 @@ pub async fn public_feed(
|
|||||||
responses((status = 200, description = "Search results: thoughts and users"))
|
responses((status = 200, description = "Search results: thoughts and users"))
|
||||||
)]
|
)]
|
||||||
pub async fn search_handler(
|
pub async fn search_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<SearchQuery>,
|
Query(q): Query<SearchQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -103,8 +129,8 @@ pub async fn search_handler(
|
|||||||
let query = q.q.trim().to_string();
|
let query = q.q.trim().to_string();
|
||||||
|
|
||||||
let (thoughts_result, users_result) = tokio::join!(
|
let (thoughts_result, users_result) = tokio::join!(
|
||||||
s.search.search_thoughts(&query, &page, viewer.as_ref()),
|
d.search.search_thoughts(&query, &page, viewer.as_ref()),
|
||||||
s.search.search_users(&query, &page),
|
d.search.search_users(&query, &page),
|
||||||
);
|
);
|
||||||
|
|
||||||
let thoughts = thoughts_result?
|
let thoughts = thoughts_result?
|
||||||
@@ -127,7 +153,7 @@ pub async fn search_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_following_handler(
|
pub async fn get_following_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -138,22 +164,22 @@ pub async fn get_following_handler(
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
if accept.contains("application/activity+json") {
|
if accept.contains("application/activity+json") {
|
||||||
let user = get_user_by_id_or_username(&*s.users, ¶m).await?;
|
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let page = q.page().try_into().ok();
|
let page = q.page().try_into().ok();
|
||||||
let json = s
|
let json = d
|
||||||
.federation
|
.federation
|
||||||
.following_collection_json(&user_id, page)
|
.following_collection_json(&user_id, page)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = get_user_by_username(&*s.users, ¶m).await?;
|
let user = get_user_by_username(&*d.users, ¶m).await?;
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_following(&*s.follows, &user.id, page).await?;
|
let result = get_following(&*d.follows, &user.id, page).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||||
@@ -162,7 +188,7 @@ pub async fn get_following_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followers_handler(
|
pub async fn get_followers_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -173,22 +199,22 @@ pub async fn get_followers_handler(
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
if accept.contains("application/activity+json") {
|
if accept.contains("application/activity+json") {
|
||||||
let user = get_user_by_id_or_username(&*s.users, ¶m).await?;
|
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let page = q.page().try_into().ok();
|
let page = q.page().try_into().ok();
|
||||||
let json = s
|
let json = d
|
||||||
.federation
|
.federation
|
||||||
.followers_collection_json(&user_id, page)
|
.followers_collection_json(&user_id, page)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = get_user_by_username(&*s.users, ¶m).await?;
|
let user = get_user_by_username(&*d.users, ¶m).await?;
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_followers(&*s.follows, &user.id, page).await?;
|
let result = get_followers(&*d.follows, &user.id, page).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||||
@@ -205,17 +231,17 @@ pub async fn get_followers_handler(
|
|||||||
responses((status = 200, description = "User's public thoughts"))
|
responses((status = 200, description = "User's public thoughts"))
|
||||||
)]
|
)]
|
||||||
pub async fn user_thoughts_handler(
|
pub async fn user_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
let user = get_user_by_username(&*d.users, &username).await?;
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_user_feed(&*s.feed, &user.id, page, viewer.as_ref()).await?;
|
let result = get_user_feed(&*d.feed, &user.id, page, viewer.as_ref()).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"page": result.page,
|
"page": result.page,
|
||||||
@@ -225,7 +251,7 @@ pub async fn user_thoughts_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_popular_tags(
|
pub async fn get_popular_tags(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let limit: usize = params
|
let limit: usize = params
|
||||||
@@ -233,7 +259,7 @@ pub async fn get_popular_tags(
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
||||||
let tags = uc_get_popular_tags(
|
let tags = uc_get_popular_tags(
|
||||||
&*s.tags,
|
&*d.tags,
|
||||||
limit.min(api_types::requests::MAX_PER_PAGE as usize),
|
limit.min(api_types::requests::MAX_PER_PAGE as usize),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -254,7 +280,7 @@ pub async fn get_popular_tags(
|
|||||||
responses((status = 200, description = "Thoughts with this tag"))
|
responses((status = 200, description = "Thoughts with this tag"))
|
||||||
)]
|
)]
|
||||||
pub async fn tag_thoughts_handler(
|
pub async fn tag_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
@@ -263,7 +289,7 @@ pub async fn tag_thoughts_handler(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_by_tag(&*s.feed, &tag_name, page, viewer.as_ref()).await?;
|
let result = get_by_tag(&*d.feed, &tag_name, page, viewer.as_ref()).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tag": tag_name,
|
"tag": tag_name,
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
use crate::state::AppState;
|
use crate::{
|
||||||
use axum::{extract::State, Json};
|
extractors::{Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use axum::Json;
|
||||||
|
use domain::ports::UserRepository;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct HealthDeps {
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for HealthDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
users: s.users.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))]
|
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))]
|
||||||
pub async fn health_handler(State(s): State<AppState>) -> Json<serde_json::Value> {
|
pub async fn health_handler(Deps(d): Deps<HealthDeps>) -> Json<serde_json::Value> {
|
||||||
let db_ok = s.users.list_with_stats().await.is_ok();
|
let db_ok = d.users.list_with_stats().await.is_ok();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"status": if db_ok { "ok" } else { "degraded" },
|
"status": if db_ok { "ok" } else { "degraded" },
|
||||||
"db": if db_ok { "connected" } else { "error" },
|
"db": if db_ok { "connected" } else { "error" },
|
||||||
|
|||||||
@@ -1,28 +1,47 @@
|
|||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::{AuthUser, Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use api_types::requests::NotificationUpdateRequest;
|
use api_types::requests::NotificationUpdateRequest;
|
||||||
use application::use_cases::notifications::{
|
use application::use_cases::notifications::{
|
||||||
count_unread_notifications, list_notifications as uc_list_notifications,
|
count_unread_notifications, list_notifications as uc_list_notifications,
|
||||||
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
use domain::{
|
||||||
|
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct NotificationsDeps {
|
||||||
|
pub notifications: Arc<dyn NotificationRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for NotificationsDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
notifications: s.notifications.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
||||||
pub async fn list_notifications(
|
pub async fn list_notifications(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<NotificationsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
};
|
};
|
||||||
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
|
||||||
let unread = count_unread_notifications(&*s.notifications, &uid).await?;
|
let unread = count_unread_notifications(&*d.notifications, &uid).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"unread": unread
|
"unread": unread
|
||||||
@@ -31,13 +50,13 @@ pub async fn list_notifications(
|
|||||||
|
|
||||||
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_notification_read(
|
pub async fn mark_notification_read(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<NotificationsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<NotificationUpdateRequest>,
|
Json(body): Json<NotificationUpdateRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
uc_mark_notification_read(
|
uc_mark_notification_read(
|
||||||
&*s.notifications,
|
&*d.notifications,
|
||||||
&NotificationId::from_uuid(id),
|
&NotificationId::from_uuid(id),
|
||||||
&uid,
|
&uid,
|
||||||
body.read,
|
body.read,
|
||||||
@@ -48,11 +67,11 @@ pub async fn mark_notification_read(
|
|||||||
|
|
||||||
#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_all_read(
|
pub async fn mark_all_read(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<NotificationsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<NotificationUpdateRequest>,
|
Json(body): Json<NotificationUpdateRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
mark_all_notifications_read(&*s.notifications, &uid, body.read).await?;
|
mark_all_notifications_read(&*d.notifications, &uid, body.read).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,86 @@
|
|||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::{AuthUser, Deps, FromAppState},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use api_types::requests::SetTopFriendsRequest;
|
use api_types::requests::SetTopFriendsRequest;
|
||||||
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
||||||
use application::use_cases::social::*;
|
use application::use_cases::social::*;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::value_objects::{ThoughtId, UserId};
|
use domain::{
|
||||||
|
ports::{
|
||||||
|
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
||||||
|
LikeRepository, TopFriendRepository, UserRepository,
|
||||||
|
},
|
||||||
|
value_objects::{ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct SocialDeps {
|
||||||
|
pub likes: Arc<dyn LikeRepository>,
|
||||||
|
pub boosts: Arc<dyn BoostRepository>,
|
||||||
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
pub blocks: Arc<dyn BlockRepository>,
|
||||||
|
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for SocialDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
likes: s.likes.clone(),
|
||||||
|
boosts: s.boosts.clone(),
|
||||||
|
follows: s.follows.clone(),
|
||||||
|
users: s.users.clone(),
|
||||||
|
federation: s.federation.clone(),
|
||||||
|
events: s.events.clone(),
|
||||||
|
blocks: s.blocks.clone(),
|
||||||
|
top_friends: s.top_friends.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_like(
|
pub async fn post_like(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
like_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))]
|
#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_like(
|
pub async fn delete_like(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
unlike_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_boost(
|
pub async fn post_boost(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
boost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))]
|
#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_boost(
|
pub async fn delete_boost(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
unboost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -53,15 +90,15 @@ pub async fn delete_boost(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn post_follow(
|
pub async fn post_follow(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
follow_actor(
|
follow_actor(
|
||||||
&*s.follows,
|
&*d.follows,
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.federation,
|
&*d.federation,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&uid,
|
&uid,
|
||||||
&username,
|
&username,
|
||||||
)
|
)
|
||||||
@@ -75,15 +112,15 @@ pub async fn post_follow(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn delete_follow(
|
pub async fn delete_follow(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
unfollow_actor(
|
unfollow_actor(
|
||||||
&*s.follows,
|
&*d.follows,
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.federation,
|
&*d.federation,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&uid,
|
&uid,
|
||||||
&username,
|
&username,
|
||||||
)
|
)
|
||||||
@@ -92,39 +129,39 @@ pub async fn delete_follow(
|
|||||||
}
|
}
|
||||||
#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_block(
|
pub async fn post_block(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
block_by_username(&*s.blocks, &*s.users, &*s.events, &uid, &username).await?;
|
block_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_block(
|
pub async fn delete_block(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
unblock_by_username(&*s.blocks, &*s.users, &*s.events, &uid, &username).await?;
|
unblock_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))]
|
#[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))]
|
||||||
pub async fn put_top_friends(
|
pub async fn put_top_friends(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<SetTopFriendsRequest>,
|
Json(body): Json<SetTopFriendsRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
|
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
|
||||||
set_top_friends(&*s.top_friends, &uid, ids).await?;
|
set_top_friends(&*d.top_friends, &uid, ids).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
#[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))]
|
#[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))]
|
||||||
pub async fn get_top_friends_handler(
|
pub async fn get_top_friends_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<SocialDeps>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
let user = get_user_by_username(&*d.users, &username).await?;
|
||||||
let friends = get_top_friends(&*s.top_friends, &user.id).await?;
|
let friends = get_top_friends(&*d.top_friends, &user.id).await?;
|
||||||
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
|
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
|
||||||
Ok(Json(serde_json::json!({ "topFriends": usernames })))
|
Ok(Json(serde_json::json!({ "topFriends": usernames })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, OptionalAuthUser},
|
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
|
||||||
handlers::auth::to_user_response,
|
handlers::auth::to_user_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -12,14 +12,38 @@ use application::use_cases::thoughts::{
|
|||||||
create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput,
|
create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::value_objects::ThoughtId;
|
use domain::{
|
||||||
|
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
|
||||||
|
value_objects::ThoughtId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct ThoughtsDeps {
|
||||||
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub tags: Arc<dyn TagRepository>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
pub outbox: Arc<dyn OutboxWriter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for ThoughtsDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
thoughts: s.thoughts.clone(),
|
||||||
|
users: s.users.clone(),
|
||||||
|
tags: s.tags.clone(),
|
||||||
|
events: s.events.clone(),
|
||||||
|
outbox: s.outbox.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn thought_to_json(
|
fn thought_to_json(
|
||||||
t: &domain::models::thought::Thought,
|
t: &domain::models::thought::Thought,
|
||||||
author: &domain::models::user::User,
|
author: &domain::models::user::User,
|
||||||
@@ -56,17 +80,17 @@ fn thought_to_json(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn post_thought(
|
pub async fn post_thought(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ThoughtsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<CreateThoughtRequest>,
|
Json(body): Json<CreateThoughtRequest>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
|
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
|
||||||
let out = create_thought(
|
let out = create_thought(
|
||||||
&*s.thoughts,
|
&*d.thoughts,
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.tags,
|
&*d.tags,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&*s.outbox,
|
&*d.outbox,
|
||||||
CreateThoughtInput {
|
CreateThoughtInput {
|
||||||
user_id: uid.clone(),
|
user_id: uid.clone(),
|
||||||
content: body.content,
|
content: body.content,
|
||||||
@@ -77,7 +101,7 @@ pub async fn post_thought(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let author = s
|
let author = d
|
||||||
.users
|
.users
|
||||||
.find_by_id(&uid)
|
.find_by_id(&uid)
|
||||||
.await?
|
.await?
|
||||||
@@ -97,12 +121,12 @@ pub async fn post_thought(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn get_thought_handler(
|
pub async fn get_thought_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ThoughtsDeps>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
OptionalAuthUser(_viewer): OptionalAuthUser,
|
OptionalAuthUser(_viewer): OptionalAuthUser,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
let thought = get_thought(&*d.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
let author = s
|
let author = d
|
||||||
.users
|
.users
|
||||||
.find_by_id(&thought.user_id)
|
.find_by_id(&thought.user_id)
|
||||||
.await?
|
.await?
|
||||||
@@ -121,11 +145,11 @@ pub async fn get_thought_handler(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn delete_thought_handler(
|
pub async fn delete_thought_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ThoughtsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
delete_thought(&*s.thoughts, &*s.events, &*s.outbox, &ThoughtId::from_uuid(id), &uid).await?;
|
delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +165,14 @@ pub async fn delete_thought_handler(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn patch_thought(
|
pub async fn patch_thought(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ThoughtsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<EditThoughtRequest>,
|
Json(body): Json<EditThoughtRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
edit_thought(
|
edit_thought(
|
||||||
&*s.thoughts,
|
&*d.thoughts,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&ThoughtId::from_uuid(id),
|
&ThoughtId::from_uuid(id),
|
||||||
&uid,
|
&uid,
|
||||||
body.content,
|
body.content,
|
||||||
@@ -165,13 +189,13 @@ pub async fn patch_thought(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn get_thread_handler(
|
pub async fn get_thread_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<ThoughtsDeps>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
||||||
let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
let thoughts = get_thread(&*d.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for t in &thoughts {
|
for t in &thoughts {
|
||||||
if let Ok(Some(author)) = s.users.find_by_id(&t.user_id).await {
|
if let Ok(Some(author)) = d.users.find_by_id(&t.user_id).await {
|
||||||
items.push(thought_to_json(t, &author, 0, 0, 0));
|
items.push(thought_to_json(t, &author, 0, 0, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, OptionalAuthUser},
|
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
|
||||||
handlers::auth::to_user_response,
|
handlers::auth::to_user_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -13,11 +13,35 @@ use application::use_cases::profile::{
|
|||||||
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use domain::ports::{
|
||||||
|
EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct UsersDeps {
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
|
pub search: Arc<dyn SearchPort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromAppState for UsersDeps {
|
||||||
|
fn from_state(s: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
users: s.users.clone(),
|
||||||
|
events: s.events.clone(),
|
||||||
|
follows: s.follows.clone(),
|
||||||
|
federation: s.federation.clone(),
|
||||||
|
search: s.search.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/users/{username}",
|
get, path = "/users/{username}",
|
||||||
@@ -28,12 +52,12 @@ use axum::{
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn get_user(
|
pub async fn get_user(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let user = get_user_by_id_or_username(&*s.users, &username).await?;
|
let user = get_user_by_id_or_username(&*d.users, &username).await?;
|
||||||
|
|
||||||
let accept = headers
|
let accept = headers
|
||||||
.get(header::ACCEPT)
|
.get(header::ACCEPT)
|
||||||
@@ -41,11 +65,11 @@ pub async fn get_user(
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
if accept.contains("application/activity+json") {
|
if accept.contains("application/activity+json") {
|
||||||
let json = s.federation.actor_json(&user.id).await?;
|
let json = d.federation.actor_json(&user.id).await?;
|
||||||
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
|
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
|
||||||
} else {
|
} else {
|
||||||
let is_followed = if let Some(viewer_id) = viewer {
|
let is_followed = if let Some(viewer_id) = viewer {
|
||||||
s.follows.find(&viewer_id, &user.id).await?.is_some()
|
d.follows.find(&viewer_id, &user.id).await?.is_some()
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
@@ -65,13 +89,13 @@ pub async fn get_user(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn patch_profile(
|
pub async fn patch_profile(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<UpdateProfileRequest>,
|
Json(body): Json<UpdateProfileRequest>,
|
||||||
) -> Result<Json<UserResponse>, ApiError> {
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
update_profile(
|
update_profile(
|
||||||
&*s.users,
|
&*d.users,
|
||||||
&*s.events,
|
&*d.events,
|
||||||
&uid,
|
&uid,
|
||||||
body.display_name,
|
body.display_name,
|
||||||
body.bio,
|
body.bio,
|
||||||
@@ -80,7 +104,7 @@ pub async fn patch_profile(
|
|||||||
body.custom_css,
|
body.custom_css,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let user = fetch_user(&*s.users, &uid).await?;
|
let user = fetch_user(&*d.users, &uid).await?;
|
||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,15 +117,15 @@ pub async fn patch_profile(
|
|||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn get_me(
|
pub async fn get_me(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<UserResponse>, ApiError> {
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
let user = fetch_user(&*s.users, &uid).await?;
|
let user = fetch_user(&*d.users, &uid).await?;
|
||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_me_following(
|
pub async fn get_me_following(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -111,7 +135,7 @@ pub async fn get_me_following(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_following(&*s.follows, &uid, page).await?;
|
let result = get_following(&*d.follows, &uid, page).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>(),
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>(),
|
||||||
@@ -119,7 +143,7 @@ pub async fn get_me_following(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_users(
|
pub async fn get_users(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
@@ -134,7 +158,7 @@ pub async fn get_users(
|
|||||||
let page_params = PageParams { page, per_page };
|
let page_params = PageParams { page, per_page };
|
||||||
|
|
||||||
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
||||||
let result = s.search.search_users(q, &page_params).await?;
|
let result = d.search.search_users(q, &page_params).await?;
|
||||||
let users: Vec<_> = result
|
let users: Vec<_> = result
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -145,7 +169,7 @@ pub async fn get_users(
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = list_users_paginated(&*s.users, page_params).await?;
|
let result = list_users_paginated(&*d.users, page_params).await?;
|
||||||
let items: Vec<_> = result
|
let items: Vec<_> = result
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -170,9 +194,9 @@ pub async fn get_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_count(
|
pub async fn get_user_count(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let count = s.users.count().await?;
|
let count = d.users.count().await?;
|
||||||
Ok(Json(serde_json::json!({ "count": count })))
|
Ok(Json(serde_json::json!({ "count": count })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +206,10 @@ pub struct LookupQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lookup_handler(
|
pub async fn lookup_handler(
|
||||||
State(s): State<AppState>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
Query(q): Query<LookupQuery>,
|
Query(q): Query<LookupQuery>,
|
||||||
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||||
let actor = s.federation.lookup_actor(&q.handle).await?;
|
let actor = d.federation.lookup_actor(&q.handle).await?;
|
||||||
Ok(Json(RemoteActorResponse {
|
Ok(Json(RemoteActorResponse {
|
||||||
handle: actor.handle,
|
handle: actor.handle,
|
||||||
display_name: actor.display_name,
|
display_name: actor.display_name,
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ pub mod routes;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod testing;
|
pub mod testing;
|
||||||
|
|
||||||
|
pub use extractors::{Deps, FromAppState};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use activitypub_base::ActivityPubRepository;
|
||||||
use domain::ports::*;
|
use domain::ports::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
testing::{NoOpOutboxWriter, TestStore},
|
testing::{NoOpOutboxWriter, TestStore},
|
||||||
value_objects::{PasswordHash, UserId},
|
value_objects::{PasswordHash, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -29,6 +30,85 @@ impl PasswordHasher for NoOpHasher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No-op ActivityPubRepository for presentation layer tests.
|
||||||
|
pub struct NoOpApRepo;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityPubRepository for NoOpApRepo {
|
||||||
|
async fn outbox_entries_for_actor(
|
||||||
|
&self,
|
||||||
|
_uid: &UserId,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn outbox_page_for_actor(
|
||||||
|
&self,
|
||||||
|
_uid: &UserId,
|
||||||
|
_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
_limit: usize,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn find_remote_actor_id(
|
||||||
|
&self,
|
||||||
|
_actor_ap_url: &str,
|
||||||
|
) -> Result<Option<UserId>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
async fn intern_remote_actor(&self, _actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||||
|
Err(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
async fn update_remote_actor_display(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_display_name: Option<&str>,
|
||||||
|
_avatar_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
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<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn apply_note_update(
|
||||||
|
&self,
|
||||||
|
_ap_id: &str,
|
||||||
|
_new_content: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
_thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
async fn get_actor_ap_urls(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn make_state() -> AppState {
|
pub fn make_state() -> AppState {
|
||||||
let store = Arc::new(TestStore::default());
|
let store = Arc::new(TestStore::default());
|
||||||
AppState {
|
AppState {
|
||||||
@@ -50,7 +130,7 @@ pub fn make_state() -> AppState {
|
|||||||
events: store.clone(),
|
events: store.clone(),
|
||||||
outbox: Arc::new(NoOpOutboxWriter),
|
outbox: Arc::new(NoOpOutboxWriter),
|
||||||
federation: store.clone(),
|
federation: store.clone(),
|
||||||
ap_repo: store.clone(),
|
ap_repo: Arc::new(NoOpApRepo),
|
||||||
remote_actor_connections: store.clone(),
|
remote_actor_connections: store.clone(),
|
||||||
federation_scheduler: store.clone(),
|
federation_scheduler: store.clone(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::sync::Arc;
|
|||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::ThoughtsObjectHandler;
|
||||||
use activitypub_base::ActivityPubService;
|
use activitypub_base::ActivityPubService;
|
||||||
use application::services::{FederationEventService, NotificationEventService};
|
use application::services::{FederationEventService, NotificationEventService};
|
||||||
use domain::ports::{ActivityPubRepository, EventPublisher, OutboundFederationPort};
|
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
||||||
|
use domain::ports::EventPublisher;
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
|
|
||||||
|
|||||||
@@ -27,25 +27,47 @@ impl OutboxRelay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: thoughts.save() and outbox.append() are not in the same DB transaction
|
||||||
|
// (known architectural limitation — fixing requires transaction-sharing between
|
||||||
|
// repositories, a larger refactor).
|
||||||
async fn process_batch(&self) -> Result<(), sqlx::Error> {
|
async fn process_batch(&self) -> Result<(), sqlx::Error> {
|
||||||
let rows = sqlx::query_as::<_, OutboxRow>(
|
// Process one row at a time inside its own transaction so that
|
||||||
|
// FOR UPDATE SKIP LOCKED actually holds the lock for the duration
|
||||||
|
// of publish + mark_delivered. A batch SELECT without a surrounding
|
||||||
|
// transaction releases locks immediately after autocommit.
|
||||||
|
loop {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OutboxRow>(
|
||||||
"SELECT seq, event_type, payload \
|
"SELECT seq, event_type, payload \
|
||||||
FROM outbox_events \
|
FROM outbox_events \
|
||||||
WHERE delivered = false \
|
WHERE delivered = false \
|
||||||
ORDER BY seq ASC \
|
ORDER BY seq ASC \
|
||||||
LIMIT 100 \
|
LIMIT 1 \
|
||||||
FOR UPDATE SKIP LOCKED",
|
FOR UPDATE SKIP LOCKED",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for row in rows {
|
let Some(row) = row else {
|
||||||
|
tx.rollback().await?;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
|
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}");
|
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}");
|
||||||
// Mark delivered to avoid blocking; investigate manually.
|
// Mark delivered to avoid blocking; investigate manually.
|
||||||
self.mark_delivered(row.seq).await?;
|
sqlx::query(
|
||||||
|
"UPDATE outbox_events \
|
||||||
|
SET delivered = true, delivered_at = now() \
|
||||||
|
WHERE seq = $1",
|
||||||
|
)
|
||||||
|
.bind(row.seq)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -54,35 +76,39 @@ impl OutboxRelay {
|
|||||||
Ok(ev) => ev,
|
Ok(ev) => ev,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}");
|
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}");
|
||||||
self.mark_delivered(row.seq).await?;
|
sqlx::query(
|
||||||
|
"UPDATE outbox_events \
|
||||||
|
SET delivered = true, delivered_at = now() \
|
||||||
|
WHERE seq = $1",
|
||||||
|
)
|
||||||
|
.bind(row.seq)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.publisher.publish(&domain_event).await {
|
match self.publisher.publish(&domain_event).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.mark_delivered(row.seq).await?;
|
|
||||||
tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");
|
|
||||||
// Leave delivered=false — will be retried next poll.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_delivered(&self, seq: i64) -> Result<(), sqlx::Error> {
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE outbox_events \
|
"UPDATE outbox_events \
|
||||||
SET delivered = true, delivered_at = now() \
|
SET delivered = true, delivered_at = now() \
|
||||||
WHERE seq = $1",
|
WHERE seq = $1",
|
||||||
)
|
)
|
||||||
.bind(seq)
|
.bind(row.seq)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");
|
||||||
|
tx.rollback().await?; // row stays undelivered, retried next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user