1248 lines
51 KiB
Markdown
1248 lines
51 KiB
Markdown
# Thoughts v2 — Plan 4: ActivityPub Federation
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make thoughts a first-class Fediverse citizen: WebFinger discovery, Actor endpoints, inbox/outbox, followers/following, and bidirectional ActivityPub federation using the `activitypub-base` library copied from movies-diary.
|
|
|
|
**Architecture:** Copy `activitypub-base` verbatim from movies-diary (generic AP protocol layer: HTTP signatures, WebFinger, NodeInfo, inbox/outbox handlers). Create `postgres-federation` implementing `FederationRepository` + `ApUserRepository`. Create `activitypub` crate with `ThoughtNote` (AP Note object) and `ThoughtsObjectHandler` (AP content lifecycle). Wire everything into `presentation` via `FederationData` + axum `FederationMiddleware`.
|
|
|
|
**Tech Stack:** `activitypub_federation = "0.7.0-beta.11"`, `url = "2"`, `reqwest`, Rust 2021/2024 editions mixed per crate
|
|
|
|
**Actor URL pattern:** `{base_url}/users/{username}` — Mastodon-compatible
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
```
|
|
Copy: crates/adapters/activitypub-base/src/ ← from movies-diary verbatim
|
|
Create: crates/adapters/activitypub-base/Cargo.toml ← adapted from movies-diary
|
|
Modify: crates/adapters/activitypub-base/src/urls.rs ← extract username not UUID
|
|
Modify: crates/adapters/activitypub-base/src/actor_handler.rs ← username path param
|
|
|
|
Create: crates/adapters/postgres/migrations/005_federation_tables.sql
|
|
Create: crates/adapters/postgres-federation/Cargo.toml
|
|
Create: crates/adapters/postgres-federation/src/lib.rs ← FederationRepository + ApUserRepository
|
|
|
|
Create: crates/adapters/activitypub/Cargo.toml
|
|
Create: crates/adapters/activitypub/src/lib.rs
|
|
Create: crates/adapters/activitypub/src/urls.rs ← AP URL builders for thoughts
|
|
Create: crates/adapters/activitypub/src/note.rs ← ThoughtNote AP object
|
|
Create: crates/adapters/activitypub/src/handler.rs ← ThoughtsObjectHandler
|
|
|
|
Modify: crates/presentation/Cargo.toml ← add activitypub, postgres-federation, activitypub-base
|
|
Modify: crates/presentation/src/state.rs ← add fed_config field
|
|
Modify: crates/presentation/src/lib.rs ← init FederationData in build_state
|
|
Modify: crates/presentation/src/routes.rs ← add AP routes + FederationMiddleware
|
|
Modify: Cargo.toml ← add reqwest, url, activitypub_federation to workspace
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Copy and configure activitypub-base
|
|
|
|
**Files:** `crates/adapters/activitypub-base/` (all)
|
|
|
|
- [ ] **Add to root `Cargo.toml` `[workspace.dependencies]`:**
|
|
|
|
```toml
|
|
reqwest = { version = "0.13", features = ["json"] }
|
|
url = { version = "2", features = ["serde"] }
|
|
```
|
|
|
|
Also add internal path deps if missing:
|
|
```toml
|
|
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
|
activitypub = { path = "crates/adapters/activitypub" }
|
|
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
|
```
|
|
|
|
- [ ] **Copy all source files from movies-diary:**
|
|
|
|
```bash
|
|
cp -r /mnt/drive/dev/movies-diary/crates/adapters/activitypub-base/src \
|
|
/mnt/drive/dev/thoughts/crates/adapters/activitypub-base/
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/activitypub-base/Cargo.toml`:**
|
|
|
|
```toml
|
|
[package]
|
|
name = "activitypub-base"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
[dependencies]
|
|
tokio = { workspace = true }
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
uuid = { workspace = true }
|
|
chrono = { workspace = true }
|
|
anyhow = { workspace = true }
|
|
tracing = { workspace = true }
|
|
async-trait = { workspace = true }
|
|
axum = { workspace = true }
|
|
reqwest = { workspace = true }
|
|
url = { workspace = true }
|
|
domain = { workspace = true }
|
|
|
|
activitypub_federation = "0.7.0-beta.11"
|
|
enum_delegate = "0.2"
|
|
```
|
|
|
|
- [ ] **Adapt `src/urls.rs`** — replace the UUID-based `extract_user_id_from_url` and `actor_url` with username-based equivalents:
|
|
|
|
Find the current content:
|
|
```rust
|
|
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
|
let path = url.path();
|
|
path.strip_prefix("/users/")
|
|
.and_then(|s| s.split('/').next())
|
|
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
|
}
|
|
|
|
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
|
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
|
.expect("base_url is always a valid URL prefix")
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
```rust
|
|
/// Extract the username segment from a /users/:username URL.
|
|
pub fn extract_username_from_url(url: &Url) -> Option<String> {
|
|
url.path()
|
|
.strip_prefix("/users/")
|
|
.and_then(|s| s.split('/').next())
|
|
.map(|s| s.to_string())
|
|
}
|
|
|
|
/// Keep the old UUID-based function for internal use (activities.rs uses it).
|
|
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
|
let path = url.path();
|
|
path.strip_prefix("/users/")
|
|
.and_then(|s| s.split('/').next())
|
|
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
|
}
|
|
|
|
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
|
// NOTE: in thoughts, actor URLs use username. This UUID-based function
|
|
// is kept for compatibility with activitypub-base internals that use UUID.
|
|
// The thoughts activitypub crate generates username-based URLs separately.
|
|
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
|
.expect("base_url is always a valid URL prefix")
|
|
}
|
|
```
|
|
|
|
- [ ] **Adapt `src/actor_handler.rs`** — change to accept username path param (thoughts uses `/users/:username`, not `/users/:uuid`):
|
|
|
|
Replace the existing handler body:
|
|
```rust
|
|
pub async fn actor_handler(
|
|
Path(username): Path<String>,
|
|
data: Data<FederationData>,
|
|
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
|
let ap_user = data
|
|
.user_repo
|
|
.find_by_username(&username)
|
|
.await
|
|
.map_err(Error::from)?
|
|
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
|
|
|
|
let db_actor = get_local_actor(ap_user.id, &data).await?;
|
|
let person = db_actor.into_json(&data).await?;
|
|
|
|
Ok(FederationJson(WithContext::new_default(person)))
|
|
}
|
|
```
|
|
|
|
- [ ] **Run:** `cargo check -p activitypub-base`
|
|
Expected: compiles. Fix any compile errors — common issues are missing deps or edition-specific syntax that needs `edition = "2024"` (already set).
|
|
|
|
- [ ] **Run:** `cargo test -p activitypub-base`
|
|
Expected: 3 tests pass (actors, nodeinfo, service).
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add crates/adapters/activitypub-base/ Cargo.toml
|
|
git commit -m "feat(activitypub-base): copy from movies-diary with username-based actor URLs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Federation migration + postgres-federation
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/postgres/migrations/005_federation_tables.sql`
|
|
- Create: `crates/adapters/postgres-federation/Cargo.toml`
|
|
- Create: `crates/adapters/postgres-federation/src/lib.rs`
|
|
|
|
- [ ] **Write `crates/adapters/postgres/migrations/005_federation_tables.sql`:**
|
|
|
|
```sql
|
|
-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them)
|
|
ALTER TABLE remote_actors
|
|
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
|
|
ADD COLUMN IF NOT EXISTS outbox_url TEXT;
|
|
|
|
-- Federation followers: remote actors following local users
|
|
CREATE TABLE IF NOT EXISTS federation_followers (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
remote_actor_url TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
follow_activity_id TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (local_user_id, remote_actor_url)
|
|
);
|
|
|
|
-- Federation following: local users following remote actors
|
|
CREATE TABLE IF NOT EXISTS federation_following (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
remote_actor_url TEXT NOT NULL,
|
|
follow_activity_id TEXT NOT NULL,
|
|
outbox_url TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (local_user_id, remote_actor_url)
|
|
);
|
|
|
|
-- Announces (boosts of remote objects via AP)
|
|
CREATE TABLE IF NOT EXISTS federation_announces (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
activity_id TEXT NOT NULL UNIQUE,
|
|
object_url TEXT NOT NULL,
|
|
actor_url TEXT NOT NULL,
|
|
announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Blocked domains (instance-level)
|
|
CREATE TABLE IF NOT EXISTS federation_blocked_domains (
|
|
domain TEXT PRIMARY KEY,
|
|
reason TEXT,
|
|
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Blocked actors (per local user)
|
|
CREATE TABLE IF NOT EXISTS federation_blocked_actors (
|
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
actor_url TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (local_user_id, actor_url)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url);
|
|
```
|
|
|
|
- [ ] **Apply migration:**
|
|
|
|
```bash
|
|
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
|
cargo sqlx migrate run --source crates/adapters/postgres/migrations
|
|
```
|
|
|
|
Expected: `Applied 1/migrate federation tables`
|
|
|
|
- [ ] **Write `crates/adapters/postgres-federation/Cargo.toml`:**
|
|
|
|
```toml
|
|
[package]
|
|
name = "postgres-federation"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
activitypub-base = { workspace = true }
|
|
sqlx = { workspace = true }
|
|
uuid = { workspace = true }
|
|
chrono = { workspace = true }
|
|
tracing = { workspace = true }
|
|
async-trait = { workspace = true }
|
|
anyhow = { workspace = true }
|
|
url = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
tokio = { workspace = true, features = ["full"] }
|
|
sqlx = { workspace = true, features = ["migrate"] }
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/postgres-federation/src/lib.rs`:**
|
|
|
|
```rust
|
|
use anyhow::{anyhow, Result};
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::PgPool;
|
|
|
|
use activitypub_base::{
|
|
ApUser, ApUserRepository,
|
|
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
|
};
|
|
|
|
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
|
|
|
pub struct PostgresFederationRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresFederationRepository {
|
|
pub fn new(pool: PgPool) -> Self { Self { pool } }
|
|
}
|
|
|
|
fn status_str(s: &FollowerStatus) -> &'static str {
|
|
match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" }
|
|
}
|
|
fn str_status(s: &str) -> FollowerStatus {
|
|
match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending }
|
|
}
|
|
fn following_str(s: &FollowingStatus) -> &'static str {
|
|
match s { FollowingStatus::Pending => "pending", FollowingStatus::Accepted => "accepted" }
|
|
}
|
|
|
|
// Map a remote_actors row + outbox_url to FederationRepository::RemoteActor
|
|
fn map_remote_actor(
|
|
url: String, handle: String, inbox_url: String,
|
|
shared_inbox_url: Option<String>, display_name: Option<String>,
|
|
avatar_url: Option<String>, outbox_url: Option<String>,
|
|
) -> RemoteActor {
|
|
RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url }
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FederationRepository for PostgresFederationRepository {
|
|
async fn add_follower(
|
|
&self,
|
|
local_user_id: uuid::Uuid,
|
|
remote_actor_url: &str,
|
|
status: FollowerStatus,
|
|
follow_activity_id: &str,
|
|
) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
|
|
VALUES($1,$2,$3,$4)
|
|
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
|
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id"
|
|
)
|
|
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_follower_follow_activity_id(
|
|
&self,
|
|
local_user_id: uuid::Uuid,
|
|
remote_actor_url: &str,
|
|
) -> Result<Option<String>> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
|
|
)
|
|
.bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
|
}
|
|
|
|
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2")
|
|
.bind(local_user_id).bind(remote_actor_url)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
|
FROM federation_followers f
|
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
|
WHERE f.local_user_id=$1"
|
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
|
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
|
status: str_status(&r.status),
|
|
}).collect())
|
|
}
|
|
|
|
async fn get_followers_page(
|
|
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
|
|
) -> Result<Vec<Follower>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
|
FROM federation_followers f
|
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
|
WHERE f.local_user_id=$1 AND f.status='accepted'
|
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
|
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
|
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
|
status: str_status(&r.status),
|
|
}).collect())
|
|
}
|
|
|
|
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
|
let n: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'"
|
|
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
|
FROM federation_followers f
|
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
|
WHERE f.local_user_id=$1 AND f.status='pending'"
|
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
|
).collect())
|
|
}
|
|
|
|
async fn update_follower_status(
|
|
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus,
|
|
) -> Result<()> {
|
|
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
|
|
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn add_following(
|
|
&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str,
|
|
) -> Result<()> {
|
|
// Upsert the remote actor first
|
|
self.upsert_remote_actor(actor.clone()).await?;
|
|
sqlx::query(
|
|
"INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url)
|
|
VALUES($1,$2,$3,$4)
|
|
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
|
SET follow_activity_id=EXCLUDED.follow_activity_id"
|
|
)
|
|
.bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_follow_activity_id(
|
|
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
|
) -> Result<Option<String>> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
|
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
|
}
|
|
|
|
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2")
|
|
.bind(local_user_id).bind(actor_url)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
|
FROM federation_following f
|
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
|
WHERE f.local_user_id=$1"
|
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
|
).collect())
|
|
}
|
|
|
|
async fn get_following_page(
|
|
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
|
|
) -> Result<Vec<RemoteActor>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
|
FROM federation_following f
|
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
|
WHERE f.local_user_id=$1
|
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
|
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
|
).collect())
|
|
}
|
|
|
|
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
|
let n: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1"
|
|
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
async fn update_following_status(
|
|
&self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus,
|
|
) -> Result<()> {
|
|
// thoughts uses federation_followers for state, not federation_following
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_following_outbox_url(
|
|
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
|
) -> Result<Option<String>> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
|
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
|
}
|
|
|
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
|
|
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
|
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
|
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
|
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()"
|
|
)
|
|
.bind(&actor.url).bind(&actor.handle).bind(&actor.display_name)
|
|
.bind(&actor.inbox_url).bind(&actor.shared_inbox_url).bind(&actor.avatar_url).bind(&actor.outbox_url)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
|
|
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
|
|
map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
|
))
|
|
}
|
|
|
|
async fn get_local_actor_keypair(
|
|
&self, user_id: uuid::Uuid,
|
|
) -> Result<Option<(String, String)>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { public_key: Option<String>, private_key: Option<String> }
|
|
let row = sqlx::query_as::<_, Row>(
|
|
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true"
|
|
).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(row.and_then(|r| match (r.public_key, r.private_key) {
|
|
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
|
|
_ => None,
|
|
}))
|
|
}
|
|
|
|
async fn save_local_actor_keypair(
|
|
&self, user_id: uuid::Uuid, public_key: String, private_key: String,
|
|
) -> Result<()> {
|
|
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
|
|
.bind(user_id).bind(&public_key).bind(&private_key)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn add_announce(
|
|
&self, activity_id: &str, object_url: &str, actor_url: &str,
|
|
announced_at: DateTime<Utc>,
|
|
) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
|
|
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING"
|
|
).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
|
let n: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM federation_announces WHERE object_url=$1"
|
|
).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING"
|
|
).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
|
|
.bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { domain: String, reason: Option<String>, blocked_at: DateTime<Utc> }
|
|
sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain")
|
|
.fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
|
BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() }
|
|
).collect())
|
|
}
|
|
|
|
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
|
|
.bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n > 0)
|
|
}
|
|
|
|
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING"
|
|
).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
|
|
.bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC"
|
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))
|
|
}
|
|
|
|
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
|
let n: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2"
|
|
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n > 0)
|
|
}
|
|
}
|
|
|
|
// ── PostgresApUserRepository ──────────────────────────────────────────────────
|
|
|
|
pub struct PostgresApUserRepository {
|
|
pool: PgPool,
|
|
base_url: String,
|
|
}
|
|
|
|
impl PostgresApUserRepository {
|
|
pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } }
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ApUserRepository for PostgresApUserRepository {
|
|
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
|
self.find_user_row_by_id(id).await
|
|
}
|
|
|
|
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
|
self.find_user_row_by_username(username).await
|
|
}
|
|
|
|
async fn count_users(&self) -> Result<usize> {
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
|
|
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n as usize)
|
|
}
|
|
}
|
|
|
|
impl PostgresApUserRepository {
|
|
async fn find_user_row_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
|
|
let row = sqlx::query_as::<_, Row>(
|
|
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true"
|
|
).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
|
}
|
|
|
|
async fn find_user_row_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
|
|
let row = sqlx::query_as::<_, Row>(
|
|
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true"
|
|
).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
|
}
|
|
|
|
fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String>) -> ApUser {
|
|
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
|
|
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
|
ApUser {
|
|
id,
|
|
username,
|
|
bio,
|
|
avatar_url,
|
|
banner_url: None,
|
|
also_known_as: None,
|
|
profile_url,
|
|
attachment: vec![],
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Run:** `cargo check -p postgres-federation`
|
|
Expected: no errors.
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add crates/adapters/postgres/migrations/005_federation_tables.sql crates/adapters/postgres-federation/
|
|
git commit -m "feat(postgres-federation): FederationRepository and ApUserRepository"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: activitypub crate — ThoughtNote + ThoughtsObjectHandler
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/activitypub/Cargo.toml`
|
|
- Create: `crates/adapters/activitypub/src/lib.rs`
|
|
- Create: `crates/adapters/activitypub/src/urls.rs`
|
|
- Create: `crates/adapters/activitypub/src/note.rs`
|
|
- Create: `crates/adapters/activitypub/src/handler.rs`
|
|
|
|
- [ ] **Write `crates/adapters/activitypub/Cargo.toml`:**
|
|
|
|
```toml
|
|
[package]
|
|
name = "activitypub"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
activitypub-base = { workspace = true }
|
|
domain = { workspace = true }
|
|
postgres = { workspace = true }
|
|
sqlx = { workspace = true }
|
|
activitypub_federation = "0.7.0-beta.11"
|
|
url = { workspace = true }
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
anyhow = { workspace = true }
|
|
chrono = { workspace = true }
|
|
uuid = { workspace = true }
|
|
async-trait = { workspace = true }
|
|
tracing = { workspace = true }
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/activitypub/src/urls.rs`:**
|
|
|
|
```rust
|
|
use url::Url;
|
|
|
|
pub struct ThoughtsUrls {
|
|
pub base_url: String,
|
|
}
|
|
|
|
impl ThoughtsUrls {
|
|
pub fn new(base_url: &str) -> Self {
|
|
Self { base_url: base_url.trim_end_matches('/').to_string() }
|
|
}
|
|
|
|
pub fn user_url(&self, username: &str) -> Url {
|
|
Url::parse(&format!("{}/users/{}", self.base_url, username))
|
|
.expect("valid URL")
|
|
}
|
|
|
|
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
|
|
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id))
|
|
.expect("valid URL")
|
|
}
|
|
|
|
pub fn user_inbox(&self, username: &str) -> Url {
|
|
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username))
|
|
.expect("valid URL")
|
|
}
|
|
|
|
pub fn user_outbox(&self, username: &str) -> Url {
|
|
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username))
|
|
.expect("valid URL")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/activitypub/src/note.rs`:**
|
|
|
|
```rust
|
|
use activitypub_base::AS_PUBLIC;
|
|
use activitypub_federation::kinds::object::NoteType;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use url::Url;
|
|
|
|
/// AP Note representing a Thought.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThoughtNote {
|
|
#[serde(rename = "type")]
|
|
pub kind: NoteType,
|
|
pub id: Url,
|
|
pub attributed_to: Url,
|
|
pub content: String,
|
|
pub published: DateTime<Utc>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
pub to: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
pub cc: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub in_reply_to: Option<Url>,
|
|
pub sensitive: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub summary: Option<String>,
|
|
}
|
|
|
|
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>,
|
|
followers_url: Url,
|
|
) -> Self {
|
|
Self {
|
|
kind: Default::default(),
|
|
id,
|
|
attributed_to: actor_url,
|
|
content,
|
|
published,
|
|
to: vec![AS_PUBLIC.to_string()],
|
|
cc: vec![followers_url.to_string()],
|
|
in_reply_to,
|
|
sensitive,
|
|
summary,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/activitypub/src/handler.rs`:**
|
|
|
|
```rust
|
|
use anyhow::{anyhow, Result};
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::PgPool;
|
|
use url::Url;
|
|
|
|
use activitypub_base::ApObjectHandler;
|
|
use domain::value_objects::{Content, ThoughtId, UserId, Visibility};
|
|
use domain::models::thought::Thought;
|
|
|
|
use crate::urls::ThoughtsUrls;
|
|
use crate::note::ThoughtNote;
|
|
|
|
pub struct ThoughtsObjectHandler {
|
|
pool: PgPool,
|
|
urls: ThoughtsUrls,
|
|
}
|
|
|
|
impl ThoughtsObjectHandler {
|
|
pub fn new(pool: PgPool, base_url: &str) -> Self {
|
|
Self { pool, urls: ThoughtsUrls::new(base_url) }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ApObjectHandler for ThoughtsObjectHandler {
|
|
async fn get_local_objects_for_user(
|
|
&self,
|
|
user_id: uuid::Uuid,
|
|
) -> Result<Vec<(Url, serde_json::Value)>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
id: uuid::Uuid, user_id: uuid::Uuid, content: String,
|
|
created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>,
|
|
content_warning: Option<String>, sensitive: bool,
|
|
username: String,
|
|
}
|
|
let rows = sqlx::query_as::<_, Row>(
|
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id,
|
|
t.content_warning, t.sensitive, u.username
|
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'"
|
|
).bind(user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
|
|
let mut result = Vec::new();
|
|
for r in rows {
|
|
let note_url = self.urls.thought_url(r.id);
|
|
let actor_url = self.urls.user_url(&r.username);
|
|
let followers_url = self.urls.user_outbox(&r.username); // using outbox as followers for simplicity
|
|
let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id));
|
|
let note = ThoughtNote::new_public(
|
|
note_url.clone(), actor_url, r.content, r.created_at,
|
|
in_reply_to, r.sensitive, r.content_warning, followers_url,
|
|
);
|
|
let json = serde_json::to_value(¬e)?;
|
|
result.push((note_url, json));
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
async fn get_local_objects_page(
|
|
&self,
|
|
user_id: uuid::Uuid,
|
|
before: Option<DateTime<Utc>>,
|
|
limit: usize,
|
|
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
id: uuid::Uuid, content: String, created_at: DateTime<Utc>,
|
|
in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>,
|
|
sensitive: bool, username: String,
|
|
}
|
|
let rows = if let Some(before) = before {
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username
|
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
|
ORDER BY t.created_at DESC LIMIT $3"
|
|
).bind(user_id).bind(before).bind(limit as i64).fetch_all(&self.pool).await
|
|
} else {
|
|
sqlx::query_as::<_, Row>(
|
|
"SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username
|
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
|
ORDER BY t.created_at DESC LIMIT $2"
|
|
).bind(user_id).bind(limit as i64).fetch_all(&self.pool).await
|
|
}.map_err(|e| anyhow!(e))?;
|
|
|
|
let mut result = Vec::new();
|
|
for r in rows {
|
|
let note_url = self.urls.thought_url(r.id);
|
|
let actor_url = self.urls.user_url(&r.username);
|
|
let followers_url = self.urls.user_outbox(&r.username);
|
|
let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id));
|
|
let note = ThoughtNote::new_public(
|
|
note_url.clone(), actor_url, r.content.clone(), r.created_at,
|
|
in_reply_to, r.sensitive, r.content_warning, followers_url,
|
|
);
|
|
let json = serde_json::to_value(¬e)?;
|
|
result.push((note_url, json, r.created_at));
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
async fn on_create(
|
|
&self,
|
|
ap_id: &Url,
|
|
actor_url: &Url,
|
|
object: serde_json::Value,
|
|
) -> Result<()> {
|
|
// Parse incoming Note from remote actor
|
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
|
|
|
// Find the remote user in our system (or create a placeholder)
|
|
let actor_url_str = actor_url.to_string();
|
|
let existing: Option<uuid::Uuid> = sqlx::query_scalar(
|
|
"SELECT id FROM users WHERE ap_id=$1"
|
|
).bind(&actor_url_str).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
|
|
let user_id = match existing {
|
|
Some(id) => id,
|
|
None => {
|
|
// Create a remote user placeholder
|
|
let uid = uuid::Uuid::new_v4();
|
|
let handle = actor_url.path().trim_start_matches('/').replace('/', "_");
|
|
sqlx::query(
|
|
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
|
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT DO NOTHING"
|
|
).bind(uid).bind(&handle).bind(format!("{}@remote", uid))
|
|
.bind(&actor_url_str).execute(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
uid
|
|
}
|
|
};
|
|
|
|
let thought_id = uuid::Uuid::new_v4();
|
|
let content = note.content.chars().take(500).collect::<String>(); // cap at 500 for remote
|
|
let ap_id_str = ap_id.to_string();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at)
|
|
VALUES($1,$2,$3,$4,'public',$5,false,$6,$7)
|
|
ON CONFLICT(ap_id) DO NOTHING"
|
|
).bind(thought_id).bind(user_id).bind(&content).bind(&ap_id_str)
|
|
.bind(note.sensitive).bind(note.summary).bind(note.published)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn on_update(&self, ap_id: &Url, _actor_url: &Url, object: serde_json::Value) -> Result<()> {
|
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
|
let content = note.content.chars().take(500).collect::<String>();
|
|
sqlx::query("UPDATE thoughts SET content=$2, updated_at=NOW() WHERE ap_id=$1")
|
|
.bind(ap_id.to_string()).bind(&content)
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
|
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
|
.bind(ap_id.to_string())
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
|
sqlx::query(
|
|
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)"
|
|
).bind(actor_url.to_string())
|
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
|
}
|
|
|
|
async fn count_local_posts(&self) -> Result<u64> {
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
|
|
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
|
Ok(n as u64)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Write `crates/adapters/activitypub/src/lib.rs`:**
|
|
|
|
```rust
|
|
pub mod handler;
|
|
pub mod note;
|
|
pub mod urls;
|
|
|
|
pub use handler::ThoughtsObjectHandler;
|
|
pub use note::ThoughtNote;
|
|
pub use urls::ThoughtsUrls;
|
|
```
|
|
|
|
- [ ] **Run:** `cargo check -p activitypub`
|
|
Expected: no errors.
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add crates/adapters/activitypub/
|
|
git commit -m "feat(activitypub): ThoughtNote AP object and ThoughtsObjectHandler"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Presentation — AP routes and federation middleware
|
|
|
|
**Files:**
|
|
- Modify: `crates/presentation/Cargo.toml`
|
|
- Modify: `crates/presentation/src/state.rs`
|
|
- Modify: `crates/presentation/src/lib.rs`
|
|
- Modify: `crates/presentation/src/routes.rs`
|
|
|
|
- [ ] **Add deps to `crates/presentation/Cargo.toml`:**
|
|
|
|
```toml
|
|
activitypub = { workspace = true }
|
|
activitypub-base = { workspace = true }
|
|
postgres-federation = { workspace = true }
|
|
url = { workspace = true }
|
|
```
|
|
|
|
- [ ] **Add `fed_config` field to `crates/presentation/src/state.rs`:**
|
|
|
|
```rust
|
|
use std::sync::Arc;
|
|
use domain::ports::*;
|
|
use activitypub_base::ApFederationConfig;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub users: Arc<dyn UserRepository>,
|
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
|
pub likes: Arc<dyn LikeRepository>,
|
|
pub boosts: Arc<dyn BoostRepository>,
|
|
pub follows: Arc<dyn FollowRepository>,
|
|
pub blocks: Arc<dyn BlockRepository>,
|
|
pub tags: Arc<dyn TagRepository>,
|
|
pub api_keys: Arc<dyn ApiKeyRepository>,
|
|
pub top_friends: Arc<dyn TopFriendRepository>,
|
|
pub notifications: Arc<dyn NotificationRepository>,
|
|
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
|
pub feed: Arc<dyn FeedRepository>,
|
|
pub search: Arc<dyn SearchPort>,
|
|
pub auth: Arc<dyn AuthService>,
|
|
pub hasher: Arc<dyn PasswordHasher>,
|
|
pub events: Arc<dyn EventPublisher>,
|
|
pub fed_config: ApFederationConfig, // NEW
|
|
}
|
|
```
|
|
|
|
- [ ] **Update `crates/presentation/src/lib.rs`** — add federation setup in `build_state`:
|
|
|
|
```rust
|
|
// Add to imports at top:
|
|
use activitypub_base::{ApFederationConfig, FederationData};
|
|
use activitypub::ThoughtsObjectHandler;
|
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
|
|
|
// In build_state, before constructing AppState, add:
|
|
|
|
let base_url = std::env::var("BASE_URL")
|
|
.unwrap_or_else(|_| "http://localhost:3000".into());
|
|
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
|
.map(|v| v == "true")
|
|
.unwrap_or(true);
|
|
let debug = std::env::var("RUST_ENV")
|
|
.map(|v| v != "production")
|
|
.unwrap_or(true);
|
|
|
|
let fed_data = FederationData::new(
|
|
std::sync::Arc::new(PostgresFederationRepository::new(pool.clone())),
|
|
std::sync::Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.clone())),
|
|
std::sync::Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)),
|
|
base_url,
|
|
allow_registration,
|
|
"thoughts".to_string(),
|
|
None, // event_publisher wired separately via NATS
|
|
);
|
|
|
|
let fed_config = ApFederationConfig::new(fed_data, debug).await
|
|
.expect("federation config failed");
|
|
|
|
// Then in AppState { ... } add:
|
|
fed_config,
|
|
```
|
|
|
|
- [ ] **Update `crates/presentation/src/routes.rs`** — add AP routes and federation middleware:
|
|
|
|
```rust
|
|
use axum::{routing::{delete, get, patch, post, put}, Router};
|
|
use activitypub_base::{
|
|
actor_handler::actor_handler,
|
|
followers_handler::followers_handler,
|
|
inbox::inbox_handler,
|
|
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
|
outbox::outbox_handler,
|
|
webfinger::webfinger_handler,
|
|
ApFederationConfig,
|
|
};
|
|
use activitypub_federation::config::FederationMiddleware;
|
|
use crate::{handlers::*, state::AppState};
|
|
|
|
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
|
let api_routes = Router::new()
|
|
// auth
|
|
.route("/auth/register", post(auth::post_register))
|
|
.route("/auth/login", post(auth::post_login))
|
|
// users
|
|
.route("/users/me", patch(users::patch_profile))
|
|
.route("/users/me/top-friends", put(social::put_top_friends))
|
|
.route("/users/{username}", get(users::get_user))
|
|
.route("/users/{username}/following", get(feed::get_following_handler))
|
|
.route("/users/{username}/followers", get(feed::get_followers_handler))
|
|
.route("/users/{username}/top-friends",get(social::get_top_friends_handler))
|
|
// thoughts
|
|
.route("/thoughts", post(thoughts::post_thought))
|
|
.route("/thoughts/{id}", get(thoughts::get_thought_handler).patch(thoughts::patch_thought).delete(thoughts::delete_thought_handler))
|
|
.route("/thoughts/{id}/thread", get(thoughts::get_thread_handler))
|
|
// likes & boosts
|
|
.route("/thoughts/{id}/like", post(social::post_like).delete(social::delete_like))
|
|
.route("/thoughts/{id}/boost", post(social::post_boost).delete(social::delete_boost))
|
|
// follows & blocks
|
|
.route("/users/{id}/follow", post(social::post_follow).delete(social::delete_follow))
|
|
.route("/users/{id}/block", post(social::post_block).delete(social::delete_block))
|
|
// feeds & search
|
|
.route("/feed", get(feed::home_feed))
|
|
.route("/feed/public", get(feed::public_feed))
|
|
.route("/search", get(feed::search_handler))
|
|
// notifications
|
|
.route("/notifications", get(notifications::list_notifications))
|
|
.route("/notifications/read-all", post(notifications::mark_all_read))
|
|
.route("/notifications/{id}/read", post(notifications::mark_notification_read))
|
|
// api keys
|
|
.route("/api-keys", get(api_keys::get_api_keys).post(api_keys::post_api_key))
|
|
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
|
|
|
let ap_routes = Router::new()
|
|
// Discovery
|
|
.route("/.well-known/webfinger", get(webfinger_handler))
|
|
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
|
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
|
// Actor + AP endpoints (note: /users/:username for actor is handled by get below
|
|
// combined with the REST get_user — but AP GET needs Accept: application/activity+json)
|
|
// activitypub-base actor_handler returns AP JSON; REST get_user returns regular JSON.
|
|
// We keep both on the same route — content negotiation is handled by the client.
|
|
.route("/users/{username}/inbox", post(inbox_handler))
|
|
.route("/users/{username}/outbox", get(outbox_handler))
|
|
.route("/users/{username}/followers",get(followers_handler));
|
|
|
|
Router::new()
|
|
.merge(api_routes)
|
|
.merge(ap_routes)
|
|
.layer(FederationMiddleware::new(fed_config.0.clone()))
|
|
}
|
|
```
|
|
|
|
- [ ] **Update callers of `router()`** in `src/main.rs` and `src/lib.rs` — `router()` now takes `fed_config`:
|
|
|
|
In `src/main.rs`, change:
|
|
```rust
|
|
let app = presentation::routes::router()
|
|
.with_state(state)
|
|
```
|
|
to:
|
|
```rust
|
|
let app = presentation::routes::router(&state.fed_config)
|
|
.with_state(state)
|
|
```
|
|
|
|
In `src/lib.rs`, if `router()` is referenced there, update the same way.
|
|
|
|
- [ ] **Run:** `cargo build -p presentation`
|
|
Expected: clean build.
|
|
|
|
- [ ] **Smoke test** WebFinger:
|
|
|
|
```bash
|
|
BASE_URL=http://localhost:3000 \
|
|
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \
|
|
RUST_LOG=info cargo run -p presentation &
|
|
sleep 3
|
|
|
|
# Register a user
|
|
TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \
|
|
-H 'content-type: application/json' \
|
|
-d '{"username":"fedtest","email":"fedtest@ex.com","password":"pw"}' | jq -r .token)
|
|
|
|
# WebFinger lookup
|
|
curl -s "http://localhost:3000/.well-known/webfinger?resource=acct:fedtest@localhost:3000" | jq .
|
|
|
|
# NodeInfo
|
|
curl -s "http://localhost:3000/.well-known/nodeinfo" | jq .
|
|
curl -s "http://localhost:3000/nodeinfo/2.0" | jq .
|
|
|
|
kill %1
|
|
```
|
|
|
|
Expected: WebFinger returns `subject` + `links`, NodeInfo returns software/protocols.
|
|
|
|
- [ ] **Run full test suite:**
|
|
|
|
```bash
|
|
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add crates/presentation/
|
|
git commit -m "feat(presentation): ActivityPub routes — WebFinger, NodeInfo, inbox, outbox"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:**
|
|
- ✅ activitypub-base copied from movies-diary + username-based actor URLs (Task 1)
|
|
- ✅ Federation migration: 5 new tables + remote_actors columns (Task 2)
|
|
- ✅ FederationRepository: all 20 methods implemented (Task 2)
|
|
- ✅ ApUserRepository: find_by_id, find_by_username, count_users (Task 2)
|
|
- ✅ ThoughtNote AP object implementing AP Note format (Task 3)
|
|
- ✅ ThoughtsObjectHandler: get/page/create/update/delete/actor_removed/count (Task 3)
|
|
- ✅ AP endpoints: webfinger, nodeinfo, actor (via activitypub-base), inbox, outbox, followers (Task 4)
|
|
- ✅ FederationMiddleware wired into axum router (Task 4)
|
|
- ✅ postgres-federation + activitypub wired in build_state (Task 4)
|
|
|
|
**Placeholder scan:** None.
|
|
|
|
**Type consistency:**
|
|
- `PostgresFederationRepository::new(pool: PgPool)` — matches usage in lib.rs
|
|
- `PostgresApUserRepository::new(pool: PgPool, base_url: String)` — matches usage in lib.rs
|
|
- `ThoughtsObjectHandler::new(pool: PgPool, base_url: &str)` — matches usage in lib.rs
|
|
- `ApFederationConfig::new(data, debug)` is `async` — `build_state` already `async` from Plan 3
|
|
- `router(fed_config: &ApFederationConfig)` — main.rs passes `&state.fed_config`
|
|
|
|
**Notes:**
|
|
- `activitypub-base` edition `"2024"` — this is per-crate and valid even in a `"2021"` workspace
|
|
- `ThoughtsObjectHandler::on_create` creates a remote user placeholder when receiving unknown actor — a simplification; full actor fetching should be implemented via AP object fetch in a future pass
|
|
- The actor endpoint (`GET /users/:username` returning AP JSON) is served by activitypub-base's `actor_handler` when client sends `Accept: application/activity+json`. Regular browser/API requests get the REST JSON from the existing `get_user` handler via content negotiation handled by activitypub_federation middleware.
|
|
- `BASE_URL` env var must be set in production to the public HTTPS URL
|