feat: implement merge readiness plan to close gaps between v2 and v1
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 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s

- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`.
- Task 2: Wire follower/following REST routes for user feeds.
- Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`.
- Task 4: Implement popular tags feature with `GET /tags/popular`.
- Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
This commit is contained in:
2026-05-14 16:28:18 +02:00
parent e6f4a6256f
commit 004bfb427b
30 changed files with 8716 additions and 808 deletions

View File

@@ -1,14 +1,14 @@
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::sync::Arc;
use url::Url;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
use activitypub_base::ApObjectHandler;
use domain::ports::ActivityPubRepository;
use domain::value_objects::UserId;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>,
@@ -17,7 +17,10 @@ pub struct ThoughtsObjectHandler {
impl ThoughtsObjectHandler {
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
Self { repo, urls: ThoughtsUrls::new(base_url) }
Self {
repo,
urls: ThoughtsUrls::new(base_url),
}
}
}
@@ -28,21 +31,34 @@ impl ApObjectHandler for ThoughtsObjectHandler {
user_id: uuid::Uuid,
) -> Result<Vec<(Url, serde_json::Value)>> {
let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_entries_for_actor(&uid).await
let entries = self
.repo
.outbox_entries_for_actor(&uid)
.await
.map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(), actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at, in_reply_to,
e.thought.sensitive, e.thought.content_warning, followers,
);
Ok((note_url, serde_json::to_value(&note)?))
}).collect()
entries
.into_iter()
.map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
Ok((note_url, serde_json::to_value(&note)?))
})
.collect()
}
async fn get_local_objects_page(
@@ -52,22 +68,35 @@ impl ApObjectHandler for ThoughtsObjectHandler {
limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await
let entries = self
.repo
.outbox_page_for_actor(&uid, before, limit)
.await
.map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| {
let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(), actor_url,
e.thought.content.as_str().to_owned(),
created_at, in_reply_to,
e.thought.sensitive, e.thought.content_warning, followers,
);
Ok((note_url, serde_json::to_value(&note)?, created_at))
}).collect()
entries
.into_iter()
.map(|e| {
let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
Ok((note_url, serde_json::to_value(&note)?, created_at))
})
.collect()
}
async fn on_create(
@@ -77,15 +106,22 @@ impl ApObjectHandler for ThoughtsObjectHandler {
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
let author_id = self.repo.intern_remote_actor(actor_url).await
let author_id = self
.repo
.intern_remote_actor(actor_url)
.await
.map_err(|e| anyhow!("{e}"))?;
self.repo.accept_note(
ap_id, &author_id,
&note.content,
note.published,
note.sensitive,
note.summary,
).await.map_err(|e| anyhow!("{e}"))
self.repo
.accept_note(
ap_id,
&author_id,
&note.content,
note.published,
note.sensitive,
note.summary,
)
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_update(
@@ -95,19 +131,30 @@ impl ApObjectHandler for ThoughtsObjectHandler {
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
self.repo.apply_note_update(ap_id, &note.content).await
self.repo
.apply_note_update(ap_id, &note.content)
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}"))
self.repo
.retract_note(ap_id)
.await
.map_err(|e| anyhow!("{e}"))
}
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}"))
self.repo
.retract_actor_notes(actor_url)
.await
.map_err(|e| anyhow!("{e}"))
}
async fn count_local_posts(&self) -> Result<u64> {
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}"))
self.repo
.count_local_notes()
.await
.map_err(|e| anyhow!("{e}"))
}
}

View File

@@ -1,5 +1,5 @@
use activitypub_base::AS_PUBLIC;
use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -27,16 +27,26 @@ pub struct ThoughtNote {
impl ThoughtNote {
pub fn new_public(
id: Url, actor_url: Url, content: String, published: DateTime<Utc>,
in_reply_to: Option<Url>, sensitive: bool, summary: Option<String>,
id: Url,
actor_url: Url,
content: String,
published: DateTime<Utc>,
in_reply_to: Option<Url>,
sensitive: bool,
summary: Option<String>,
followers_url: Url,
) -> Self {
Self {
kind: Default::default(),
id, attributed_to: actor_url, content, published,
id,
attributed_to: actor_url,
content,
published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to, sensitive, summary,
in_reply_to,
sensitive,
summary,
}
}
}
@@ -52,7 +62,9 @@ mod tests {
"https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(),
chrono::Utc::now(),
None, false, None,
None,
false,
None,
"https://example.com/users/alice/followers".parse().unwrap(),
);
let json = serde_json::to_string(&note).unwrap();

View File

@@ -6,7 +6,9 @@ pub struct ThoughtsUrls {
impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self {
Self { base_url: base_url.trim_end_matches('/').to_string() }
Self {
base_url: base_url.trim_end_matches('/').to_string(),
}
}
pub fn user_url(&self, username: &str) -> Url {
@@ -37,13 +39,19 @@ mod tests {
#[test]
fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice");
assert_eq!(
urls.user_url("alice").as_str(),
"https://example.com/users/alice"
);
}
#[test]
fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil();
assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/"));
assert!(urls
.thought_url(id)
.as_str()
.starts_with("https://example.com/thoughts/"));
}
}