refactor(ports): ActivityPubRepository takes &str instead of url::Url — infra type stays in adapter

This commit is contained in:
2026-05-15 14:06:33 +02:00
parent c76894e527
commit 3f6b91c943
5 changed files with 69 additions and 85 deletions

View File

@@ -5,7 +5,6 @@ const MAX_REMOTE_CONTENT_CHARS: usize = 500;
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use url::Url;
use domain::{
errors::DomainError,
@@ -139,17 +138,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
async fn find_remote_actor_id(
&self,
actor_ap_url: &Url,
actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url.as_str())
.bind(actor_ap_url)
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(UserId::from_uuid))
}
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(id);
}
@@ -157,11 +156,13 @@ impl ActivityPubRepository for PgActivityPubRepository {
// Use the last path segment as username (e.g. /users/alice → "alice").
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
// username column is VARCHAR(32).
let last_seg = actor_ap_url
.path_segments()
.and_then(|mut s| s.next_back())
.unwrap_or("")
.to_string();
let last_seg = url::Url::parse(actor_ap_url)
.ok()
.and_then(|u| {
u.path_segments()
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
})
.unwrap_or_default();
let handle = if last_seg.is_empty() {
format!("remote_{}", &new_id.to_string()[..13])
} else if last_seg.len() <= 32 {
@@ -176,7 +177,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.bind(new_id)
.bind(&handle)
.bind(format!("{}@remote", new_id))
.bind(actor_ap_url.as_str())
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()?;
@@ -211,25 +212,26 @@ impl ActivityPubRepository for PgActivityPubRepository {
async fn accept_note(
&self,
ap_id: &Url,
ap_id: &str,
author_id: &UserId,
content: &str,
published: DateTime<Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&Url>,
in_reply_to: Option<&str>,
) -> Result<(), DomainError> {
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => {
// If the parent is a local thought, extract its UUID for in_reply_to_id.
let local_uuid = url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
(local_uuid, Some(url.as_str().to_string()))
let local_uuid = url::Url::parse(url).ok().and_then(|u| {
u.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
});
(local_uuid, Some(url.to_string()))
}
None => (None, None),
};
@@ -240,7 +242,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.bind(uuid::Uuid::new_v4())
.bind(author_id.as_uuid())
.bind(&capped)
.bind(ap_id.as_str())
.bind(ap_id)
.bind(sensitive)
.bind(content_warning)
.bind(published)
@@ -253,12 +255,12 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ())
}
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
sqlx::query(
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
)
.bind(ap_id.as_str())
.bind(ap_id)
.bind(&capped)
.execute(&self.pool)
.await
@@ -266,20 +268,20 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ())
}
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id.as_str())
.bind(ap_id)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> {
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
)
.bind(actor_ap_url.as_str())
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()
@@ -331,20 +333,20 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = url::Url::parse("https://mastodon.social/users/alice").unwrap();
let id1 = repo.intern_remote_actor(&url).await.unwrap();
let id2 = repo.intern_remote_actor(&url).await.unwrap();
let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(
&ap_id,
ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
@@ -355,7 +357,7 @@ mod tests {
)
.await
.unwrap();
repo.retract_note(&ap_id).await.unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]