Compare commits
52 Commits
master
...
1866eef770
| Author | SHA1 | Date | |
|---|---|---|---|
| 1866eef770 | |||
| 137d1a0c6a | |||
| 4f990afe5e | |||
| fb8c75af72 | |||
| 2524440fe4 | |||
| 6082766935 | |||
| e408a53136 | |||
| 68fe8624cd | |||
| 1127a5946f | |||
| f0b87311e3 | |||
| ea14035062 | |||
| 4ae3af8086 | |||
| e0b0a71f1d | |||
| 5f8e96b9be | |||
| 54bd1c193b | |||
| e0a27c99a4 | |||
| 2080fec347 | |||
| 21b6a04f97 | |||
| ebc612a311 | |||
| c9b389a00c | |||
| 3318635da6 | |||
| 2e702c64cc | |||
| 2cee884fe1 | |||
| a0893b1c69 | |||
| 57232705fe | |||
| 02de6b6f83 | |||
| b599047d98 | |||
| 4eeaea2a14 | |||
| ebf0aaab58 | |||
| a3534317de | |||
| 6e5d0de636 | |||
| bfe6db2215 | |||
| f75e796faf | |||
| c5d262c68f | |||
| 38106ecdb6 | |||
| fb39ea2469 | |||
| adc2102927 | |||
| 134ecdcfb4 | |||
| 2b428b2b0a | |||
| 69608cfc75 | |||
| 02ce3a49b4 | |||
| 1dab9ffbfb | |||
| 9dd04541ac | |||
| fe9655ee96 | |||
| 62ee73e302 | |||
| 80b656341d | |||
| 4b8d1027c1 | |||
| 94a3f414e4 | |||
| 63a7001165 | |||
| 321571aae9 | |||
| 9d6e3298f1 | |||
| 6fd9a76e68 |
@@ -1,9 +0,0 @@
|
||||
[registry]
|
||||
default = "gitea"
|
||||
|
||||
[registries.gitea]
|
||||
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" # Sparse index
|
||||
# index = "https://git.gabrielkaszewski.dev/GKaszewski/_cargo-index.git" # Git
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(git commit*)",
|
||||
"command": "cargo fmt --all 2>&1 && cargo clippy --workspace 2>&1 || echo '{\"continue\": false, \"stopReason\": \"cargo fmt or clippy failed — fix before committing\"}'",
|
||||
"timeout": 120,
|
||||
"statusMessage": "Running cargo fmt + clippy..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
10
.env
Normal file
10
.env
Normal file
@@ -0,0 +1,10 @@
|
||||
POSTGRES_USER=thoughts_user
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=thoughts_db
|
||||
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db"
|
||||
PREFORK=1
|
||||
AUTH_SECRET=secret
|
||||
BASE_URL=http://0.0.0.0
|
||||
47
.env.example
47
.env.example
@@ -1,44 +1,3 @@
|
||||
# Database (PostgreSQL required)
|
||||
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
|
||||
|
||||
# Authentication
|
||||
JWT_SECRET=change-me
|
||||
|
||||
# Public base URL — used for ActivityPub actor URLs and canonical links
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# Optional
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
||||
CORS_ORIGINS=*
|
||||
# CORS_ORIGINS=https://your-nextjs-app.example.com
|
||||
|
||||
# Rate limiting — max requests per minute per IP (disabled by default)
|
||||
# RATE_LIMIT=60
|
||||
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
|
||||
RUST_ENV=development # set to "production" to disable AP debug mode
|
||||
|
||||
# NATS event bus (optional — federation and notifications still work without it,
|
||||
# but events will not be delivered to the worker)
|
||||
# NATS_URL=nats://localhost:4222
|
||||
|
||||
# Media storage — local filesystem (default) or S3/MinIO
|
||||
STORAGE_BACKEND=local
|
||||
STORAGE_PATH=./media # required when STORAGE_BACKEND=local
|
||||
# STORAGE_PREFIX= # optional key prefix
|
||||
|
||||
# S3/MinIO (set STORAGE_BACKEND=s3 to use)
|
||||
# S3_ENDPOINT=http://localhost:9000
|
||||
# S3_ACCESS_KEY_ID=minioadmin
|
||||
# S3_SECRET_ACCESS_KEY=minioadmin
|
||||
# S3_BUCKET=thoughts
|
||||
# S3_REGION=us-east-1
|
||||
|
||||
# Upload limits (optional, defaults shown)
|
||||
# UPLOAD_MAX_BYTES=5242880
|
||||
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif
|
||||
|
||||
# Logging
|
||||
RUST_LOG=info
|
||||
POSTGRES_USER=thoughts_user
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=thoughts_db
|
||||
@@ -1,19 +1,41 @@
|
||||
name: deploy
|
||||
name: Build and Deploy Thoughts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
build-and-deploy-local:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
docker pull registry.gabrielkaszewski.dev/thoughts:latest
|
||||
docker pull registry.gabrielkaszewski.dev/thoughts-frontend:latest
|
||||
docker compose -f /opt/thoughts/docker-compose.yml up -d
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
|
||||
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
|
||||
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
|
||||
echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env
|
||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env
|
||||
|
||||
- name: Build Docker Images Manually
|
||||
run: |
|
||||
docker build --target runtime -t thoughts-backend:latest ./thoughts-backend
|
||||
docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend
|
||||
docker build -t custom-proxy:latest ./nginx
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
docker compose -f compose.prod.yml down
|
||||
|
||||
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
|
||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
|
||||
POSTGRES_DB=${{ secrets.POSTGRES_DB }} \
|
||||
AUTH_SECRET=${{ secrets.AUTH_SECRET }} \
|
||||
docker compose -f compose.prod.yml up -d
|
||||
|
||||
docker image prune -f
|
||||
@@ -1,24 +0,0 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
@@ -1,23 +0,0 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# Unit tests — no database required.
|
||||
# All business logic is tested via TestStore (in-memory port implementations).
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: unit tests
|
||||
run: |
|
||||
cargo test --workspace \
|
||||
--exclude postgres \
|
||||
--exclude postgres-federation \
|
||||
--exclude postgres-search
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "→ cargo fmt"
|
||||
if ! cargo fmt --all -- --check; then
|
||||
echo " run 'cargo fmt --all' to fix formatting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ cargo clippy"
|
||||
if ! cargo clippy --workspace -- -D warnings; then
|
||||
exit 1
|
||||
fi
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,3 @@
|
||||
.env
|
||||
/.superpowers/
|
||||
|
||||
/target
|
||||
/docs/superpowers/
|
||||
/media
|
||||
backend-codebase.txt
|
||||
frontend-codebase.txt
|
||||
.env
|
||||
165
API Design.md
Normal file
165
API Design.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# **Thoughts \- API Design (Version 1\)**
|
||||
|
||||
## **1\. Overview**
|
||||
|
||||
This document specifies the RESTful API for the Thoughts platform.
|
||||
|
||||
* **Base URL:** /api/v1
|
||||
* **Data Format:** All requests and responses will be in JSON format.
|
||||
* **Authentication:** The API uses two primary methods for authentication:
|
||||
1. **JWT (JSON Web Tokens):** For the official web client. The POST /api/v1/auth/login endpoint returns a short-lived JWT. This token must be included in the Authorization: Bearer \<token\> header for all subsequent authenticated requests.
|
||||
2. **API Keys:** For third-party applications. Users can generate long-lived API keys. These keys must be included in the Authorization: ApiKey \<key\> header.
|
||||
|
||||
## **2\. API Endpoints**
|
||||
|
||||
### **Auth Endpoints**
|
||||
|
||||
**POST /auth/register**
|
||||
|
||||
* **Description:** Creates a new user account.
|
||||
* **Authentication:** Public.
|
||||
* **Request Body:**
|
||||
{
|
||||
"username": "frutiger",
|
||||
"email": "aero@example.com",
|
||||
"password": "strongpassword123"
|
||||
}
|
||||
|
||||
* **Success Response:** 201 Created with the new User object (password omitted).
|
||||
* **Error Responses:** 400 Bad Request (invalid input), 409 Conflict (username or email already exists).
|
||||
|
||||
**POST /auth/login**
|
||||
|
||||
* **Description:** Authenticates a user and returns a JWT.
|
||||
* **Authentication:** Public.
|
||||
* **Request Body:**
|
||||
{
|
||||
"username": "frutiger",
|
||||
"password": "strongpassword123"
|
||||
}
|
||||
|
||||
* **Success Response:** 200 OK with a JWT.
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
|
||||
* **Error Responses:** 400 Bad Request, 401 Unauthorized.
|
||||
|
||||
### **User & Profile Endpoints**
|
||||
|
||||
**GET /users/{username}**
|
||||
|
||||
* **Description:** Retrieves the public profile of a user.
|
||||
* **Authentication:** Public.
|
||||
* **Success Response:** 200 OK with a public User object.
|
||||
|
||||
**GET /users/me**
|
||||
|
||||
* **Description:** Retrieves the full profile of the currently authenticated user (including private details like email).
|
||||
* **Authentication:** Required (JWT).
|
||||
* **Success Response:** 200 OK with the full User object.
|
||||
|
||||
**PUT /users/me**
|
||||
|
||||
* **Description:** Updates the profile of the currently authenticated user.
|
||||
* **Authentication:** Required (JWT).
|
||||
* **Request Body:**
|
||||
{
|
||||
"displayName": "Frutiger Aero Fan",
|
||||
"bio": "Est. 2004",
|
||||
"avatarUrl": "https://...",
|
||||
"headerUrl": "https://...",
|
||||
"customCss": "body { background: blue; }",
|
||||
"topFriends": \["username1", "username2"\]
|
||||
}
|
||||
|
||||
* **Success Response:** 200 OK with the updated User object.
|
||||
* **Error Responses:** 400 Bad Request.
|
||||
|
||||
### **Thoughts (Posts) Endpoints**
|
||||
|
||||
**POST /thoughts**
|
||||
|
||||
* **Description:** Creates a new thought.
|
||||
* **Authentication:** Required (JWT or API Key).
|
||||
* **Request Body:**
|
||||
{
|
||||
"content": "This is my first thought\! \#welcome"
|
||||
}
|
||||
|
||||
* **Success Response:** 201 Created with the new Thought object.
|
||||
* **Error Responses:** 400 Bad Request (e.g., content \> 128 chars).
|
||||
|
||||
**GET /users/{username}/thoughts**
|
||||
|
||||
* **Description:** Retrieves all thoughts for a specific user, paginated.
|
||||
* **Authentication:** Public.
|
||||
* **Success Response:** 200 OK with an array of Thought objects.
|
||||
|
||||
**DELETE /thoughts/{id}**
|
||||
|
||||
* **Description:** Deletes a thought. The user must be the author.
|
||||
* **Authentication:** Required (JWT or API Key).
|
||||
* **Success Response:** 204 No Content.
|
||||
* **Error Responses:** 403 Forbidden, 404 Not Found.
|
||||
|
||||
### **Social Endpoints**
|
||||
|
||||
**POST /users/{username}/follow**
|
||||
|
||||
* **Description:** Follows a user.
|
||||
* **Authentication:** Required (JWT).
|
||||
* **Success Response:** 204 No Content.
|
||||
* **Error Responses:** 404 Not Found, 409 Conflict (already following).
|
||||
|
||||
**DELETE /users/{username}/follow**
|
||||
|
||||
* **Description:** Unfollows a user.
|
||||
* **Authentication:** Required (JWT).
|
||||
* **Success Response:** 204 No Content.
|
||||
* **Error Responses:** 404 Not Found.
|
||||
|
||||
**GET /feed**
|
||||
|
||||
* **Description:** Retrieves the main feed for the authenticated user, paginated.
|
||||
* **Authentication:** Required (JWT).
|
||||
* **Success Response:** 200 OK with an array of Thought objects from followed users.
|
||||
|
||||
### **Discovery Endpoints**
|
||||
|
||||
**GET /tags/popular**
|
||||
|
||||
* **Description:** Retrieves a list of currently popular tags.
|
||||
* **Authentication:** Public.
|
||||
* **Success Response:** 200 OK with an array of tag strings.
|
||||
|
||||
**GET /tags/{tagName}**
|
||||
|
||||
* **Description:** Retrieves a feed of all thoughts with a specific tag, paginated.
|
||||
* **Authentication:** Public.
|
||||
* **Success Response:** 200 OK with an array of Thought objects.
|
||||
|
||||
## **3\. Data Models**
|
||||
|
||||
**User Object (Public)**
|
||||
|
||||
{
|
||||
"username": "frutiger",
|
||||
"displayName": "Frutiger Aero Fan",
|
||||
"bio": "Est. 2004",
|
||||
"avatarUrl": "https://...",
|
||||
"headerUrl": "https://...",
|
||||
"customCss": "body { background: blue; }",
|
||||
"topFriends": \["username1", "username2"\],
|
||||
"joinedAt": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
|
||||
**Thought Object**
|
||||
|
||||
{
|
||||
"id": "uuid-v4-string",
|
||||
"authorUsername": "frutiger",
|
||||
"content": "This is my first thought\! \#welcome",
|
||||
"tags": \["welcome"\],
|
||||
"createdAt": "2024-01-01T12:01:00Z"
|
||||
}
|
||||
164
ARCHITECTURE.md
164
ARCHITECTURE.md
@@ -1,164 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
Hexagonal (ports & adapters) architecture. Dependencies point inward — adapters implement domain ports, application orchestrates use cases, presentation handles HTTP.
|
||||
|
||||
## Crate dependency graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Entry Points
|
||||
bootstrap["bootstrap<br/><small>HTTP server, DI wiring</small>"]
|
||||
worker["worker<br/><small>background job consumer</small>"]
|
||||
end
|
||||
|
||||
subgraph Interface Layer
|
||||
presentation["presentation<br/><small>axum handlers, extractors, AppState</small>"]
|
||||
api_types["api-types<br/><small>DTOs, OpenAPI</small>"]
|
||||
end
|
||||
|
||||
subgraph Application Layer
|
||||
application["application<br/><small>use cases, FederationEventService</small>"]
|
||||
end
|
||||
|
||||
subgraph Domain Layer
|
||||
domain["domain<br/><small>models, value objects, events, port traits</small>"]
|
||||
end
|
||||
|
||||
subgraph Adapters
|
||||
postgres["postgres<br/><small>UserRepo, ThoughtRepo, LikeRepo,<br/>BoostRepo, FollowRepo, BlockRepo,<br/>TagRepo, FeedRepo, FederationContentRepo, ...</small>"]
|
||||
activitypub["activitypub<br/><small>FederationActionPort,<br/>FederationBroadcastPort,<br/>FederationSchedulerPort<br/>(wraps k-ap)</small>"]
|
||||
postgres_fed["postgres-federation<br/><small>k-ap DB traits</small>"]
|
||||
postgres_search["postgres-search<br/><small>SearchPort</small>"]
|
||||
auth["auth<br/><small>AuthService, ApiKeyService</small>"]
|
||||
nats["nats<br/><small>EventPublisher, EventConsumer</small>"]
|
||||
storage["storage<br/><small>MediaStore</small>"]
|
||||
event_transport["event-transport<br/><small>event delivery</small>"]
|
||||
event_payload["event-payload<br/><small>event serialization</small>"]
|
||||
end
|
||||
|
||||
bootstrap --> presentation
|
||||
bootstrap --> application
|
||||
bootstrap --> postgres
|
||||
bootstrap --> postgres_fed
|
||||
bootstrap --> postgres_search
|
||||
bootstrap --> activitypub
|
||||
bootstrap --> auth
|
||||
bootstrap --> nats
|
||||
bootstrap --> storage
|
||||
bootstrap --> event_transport
|
||||
bootstrap --> event_payload
|
||||
|
||||
worker --> application
|
||||
worker --> activitypub
|
||||
worker --> postgres
|
||||
worker --> postgres_fed
|
||||
worker --> nats
|
||||
worker --> event_transport
|
||||
worker --> event_payload
|
||||
|
||||
presentation --> application
|
||||
presentation --> api_types
|
||||
presentation --> domain
|
||||
|
||||
application --> domain
|
||||
|
||||
postgres --> domain
|
||||
activitypub --> domain
|
||||
postgres_fed -.-> domain
|
||||
postgres_search --> domain
|
||||
postgres_search --> postgres
|
||||
auth --> domain
|
||||
nats --> domain
|
||||
storage --> domain
|
||||
event_transport --> domain
|
||||
event_payload --> domain
|
||||
```
|
||||
|
||||
## Domain ports
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class domain {
|
||||
<<core>>
|
||||
}
|
||||
|
||||
namespace Data Ports {
|
||||
class UserRepository {
|
||||
<<trait>>
|
||||
find_by_id()
|
||||
find_by_username()
|
||||
save()
|
||||
update_profile()
|
||||
}
|
||||
class ThoughtRepository {
|
||||
<<trait>>
|
||||
save()
|
||||
find_by_id()
|
||||
delete()
|
||||
update_content()
|
||||
}
|
||||
class LikeRepository { <<trait>> }
|
||||
class BoostRepository { <<trait>> }
|
||||
class FollowRepository { <<trait>> }
|
||||
class BlockRepository { <<trait>> }
|
||||
class TagRepository { <<trait>> }
|
||||
class FeedRepository { <<trait>> }
|
||||
class NotificationRepository { <<trait>> }
|
||||
class EngagementRepository { <<trait>> }
|
||||
class SearchPort { <<trait>> }
|
||||
}
|
||||
|
||||
namespace Federation Ports {
|
||||
class FederationContentRepository {
|
||||
<<trait>>
|
||||
outbox_entries_for_actor()
|
||||
find_remote_actor_id()
|
||||
intern_remote_actor()
|
||||
accept_note()
|
||||
retract_note()
|
||||
}
|
||||
class FederationBroadcastPort {
|
||||
<<trait>>
|
||||
broadcast_create()
|
||||
broadcast_delete()
|
||||
broadcast_update()
|
||||
broadcast_announce()
|
||||
broadcast_like()
|
||||
}
|
||||
class FederationActionPort {
|
||||
<<supertrait>>
|
||||
}
|
||||
class FederationLookupPort { <<trait>> }
|
||||
class FederationFollowPort { <<trait>> }
|
||||
class FederationFollowRequestPort { <<trait>> }
|
||||
class FederationFetchPort { <<trait>> }
|
||||
class FederationBlockPort { <<trait>> }
|
||||
class FederationSchedulerPort { <<trait>> }
|
||||
}
|
||||
|
||||
namespace Infra Ports {
|
||||
class EventPublisher { <<trait>> }
|
||||
class EventConsumer { <<trait>> }
|
||||
class AuthService { <<trait>> }
|
||||
class PasswordHasher { <<trait>> }
|
||||
class MediaStore { <<trait>> }
|
||||
}
|
||||
|
||||
FederationActionPort --|> FederationLookupPort
|
||||
FederationActionPort --|> FederationFollowPort
|
||||
FederationActionPort --|> FederationFollowRequestPort
|
||||
FederationActionPort --|> FederationFetchPort
|
||||
FederationActionPort --|> FederationBlockPort
|
||||
```
|
||||
|
||||
## Dependency rule
|
||||
|
||||
```
|
||||
bootstrap/worker ──► presentation ──► application ──► domain ◄── adapters
|
||||
```
|
||||
|
||||
- **domain** — zero framework deps, pure business logic, defines all port traits
|
||||
- **application** — orchestrates use cases, depends only on domain
|
||||
- **presentation** — HTTP handlers (axum), depends on domain + application
|
||||
- **adapters** — implement domain ports, depend inward on domain only
|
||||
- **bootstrap/worker** — composition roots, wire adapters into ports
|
||||
15
Cargo.toml
15
Cargo.toml
@@ -4,17 +4,16 @@ members = [
|
||||
"crates/application",
|
||||
"crates/api-types",
|
||||
"crates/presentation",
|
||||
"crates/bootstrap",
|
||||
"crates/worker",
|
||||
"crates/adapters/postgres",
|
||||
"crates/adapters/postgres-search",
|
||||
"crates/adapters/postgres-federation",
|
||||
"crates/adapters/activitypub-base",
|
||||
"crates/adapters/activitypub",
|
||||
"crates/adapters/auth",
|
||||
"crates/adapters/nats",
|
||||
"crates/adapters/event-payload",
|
||||
"crates/adapters/event-transport",
|
||||
"crates/adapters/storage",
|
||||
"crates/adapters/event-publisher",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -30,26 +29,24 @@ async-trait = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
futures = "0.3"
|
||||
bytes = "1.0"
|
||||
dotenvy = "0.15"
|
||||
async-nats = "0.48"
|
||||
async-nats = "0.38"
|
||||
async-stream = "0.3"
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
url = { version = "2", features = ["serde"] }
|
||||
|
||||
presentation = { path = "crates/presentation" }
|
||||
domain = { path = "crates/domain" }
|
||||
application = { path = "crates/application" }
|
||||
api-types = { path = "crates/api-types" }
|
||||
postgres = { path = "crates/adapters/postgres" }
|
||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
||||
activitypub = { path = "crates/adapters/activitypub" }
|
||||
auth = { path = "crates/adapters/auth" }
|
||||
nats = { path = "crates/adapters/nats" }
|
||||
event-payload = { path = "crates/adapters/event-payload" }
|
||||
event-transport = { path = "crates/adapters/event-transport" }
|
||||
storage = { path = "crates/adapters/storage" }
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
|
||||
114
Database schema.md
Normal file
114
Database schema.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# **Thoughts \- Database Schema (PostgreSQL)**
|
||||
|
||||
## **1\. Overview**
|
||||
|
||||
This document outlines the table structure for the Thoughts platform using PostgreSQL. The design uses UUIDs for primary keys to facilitate decentralization and prevent enumeration attacks. All timestamps are stored with time zones (TIMESTAMPTZ).
|
||||
|
||||
## **2\. Schema Diagram (ERD)**
|
||||
|
||||
\+-------------+ \+--------------+ \+--------------+
|
||||
| users |\<--+--| thoughts |---+--|\> thought\_tags |
|
||||
\+-------------+ | \+--------------+ | \+--------------+
|
||||
| | | ^
|
||||
| | | |
|
||||
| | \+--------------+ | \+--------------+
|
||||
\+--------+--+--|\> follows |\<--+-+--| tags |
|
||||
| | \+--------------+ | \+--------------+
|
||||
| | |
|
||||
v | |
|
||||
\+-------------+ | |
|
||||
| top\_friends |\<-+ |
|
||||
\+-------------+ |
|
||||
| |
|
||||
v |
|
||||
\+-------------+ |
|
||||
| api\_keys |\<--------------------------+
|
||||
\+-------------+
|
||||
|
||||
*(Note: Arrows denote foreign key relationships)*
|
||||
|
||||
## **3\. Table Definitions**
|
||||
|
||||
### **users**
|
||||
|
||||
Stores user account and profile information.
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the user. |
|
||||
| username | VARCHAR(32) | NOT NULL, UNIQUE | The user's handle. |
|
||||
| email | VARCHAR(255) | NOT NULL, UNIQUE | The user's email address. |
|
||||
| password\_hash | TEXT | NOT NULL | Hashed password (using Argon2 or bcrypt). |
|
||||
| display\_name | VARCHAR(50) | NULL | User's public display name. |
|
||||
| bio | VARCHAR(160) | NULL | User's public biography. |
|
||||
| avatar\_url | TEXT | NULL | URL to the user's avatar image. |
|
||||
| header\_url | TEXT | NULL | URL to the user's header image. |
|
||||
| custom\_css | TEXT | NULL | User's custom profile CSS. |
|
||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of account creation. |
|
||||
| updated\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of the last profile update. |
|
||||
|
||||
### **thoughts**
|
||||
|
||||
Stores the content of each post.
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the thought. |
|
||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The ID of the authoring user. |
|
||||
| content | VARCHAR(128) | NOT NULL | The text content of the thought. |
|
||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the thought was posted. |
|
||||
|
||||
### **follows**
|
||||
|
||||
A join table representing the follower/following relationship.
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| follower\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is initiating the follow. |
|
||||
| following\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is being followed. |
|
||||
| | | PRIMARY KEY (follower\_id, following\_id) | Ensures a user can't follow someone twice. |
|
||||
|
||||
### **top\_friends**
|
||||
|
||||
Stores the ordered list of a user's "Top Friends".
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The owner of this "Top Friends" list. |
|
||||
| friend\_id | UUID | NOT NULL, REFERENCES users(id) | The user being displayed as a friend. |
|
||||
| position | SMALLINT | NOT NULL | The order (1-8) of the friend on the list. |
|
||||
| | | PRIMARY KEY (user\_id, friend\_id) | Ensures a user can't be in the list twice. |
|
||||
| | | UNIQUE (user\_id, position) | Ensures positions are not duplicated. |
|
||||
|
||||
### **tags and thought\_tags (for hashtags)**
|
||||
|
||||
* **tags**: Stores unique tag names.
|
||||
* **thought\_tags**: A join table linking thoughts to tags.
|
||||
|
||||
#### **tags**
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| id | SERIAL | PRIMARY KEY | Unique ID for the tag. |
|
||||
| name | VARCHAR(50) | NOT NULL, UNIQUE | The tag name (e.g., "welcome"). |
|
||||
|
||||
#### **thought\_tags**
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| thought\_id | UUID | NOT NULL, REFERENCES thoughts(id) | The ID of the thought. |
|
||||
| tag\_id | INTEGER | NOT NULL, REFERENCES tags(id) | The ID of the tag. |
|
||||
| | | PRIMARY KEY (thought\_id, tag\_id) | Prevents duplicate tags per post. |
|
||||
|
||||
### **api\_keys**
|
||||
|
||||
Stores hashed API keys for users.
|
||||
|
||||
| Column Name | Data Type | Constraints | Description |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the API key. |
|
||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The user who owns this key. |
|
||||
| key\_hash | TEXT | NOT NULL, UNIQUE | The hashed value of the API key. |
|
||||
| name | VARCHAR(50) | NOT NULL | A user-provided name for the key. |
|
||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the key was created. |
|
||||
|
||||
59
Dockerfile
59
Dockerfile
@@ -1,59 +0,0 @@
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependency compilation separately from source
|
||||
COPY .cargo/ .cargo/
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/adapters/storage/Cargo.toml crates/adapters/storage/Cargo.toml
|
||||
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
||||
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
|
||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# Now copy real source and build
|
||||
COPY crates ./crates
|
||||
|
||||
RUN cargo build --release -p bootstrap -p worker --features storage/s3
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/thoughts ./thoughts
|
||||
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
CMD ["./thoughts"]
|
||||
48
Makefile
48
Makefile
@@ -1,48 +0,0 @@
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Run the full local check suite — same order as CI would.
|
||||
check: fmt-check clippy test
|
||||
@echo "✅ All checks passed"
|
||||
|
||||
# Apply rustfmt to all files.
|
||||
fmt:
|
||||
cargo fmt
|
||||
|
||||
# Check formatting without modifying files (CI-safe).
|
||||
fmt-check:
|
||||
cargo fmt --check
|
||||
|
||||
# Run Clippy and treat warnings as errors.
|
||||
clippy:
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# Run the full test suite (requires DATABASE_URL).
|
||||
test:
|
||||
cargo test
|
||||
|
||||
# Unit tests only — no database required.
|
||||
test-unit:
|
||||
cargo test -p domain -p application -p api-types -p activitypub
|
||||
|
||||
# Integration tests only — requires DATABASE_URL.
|
||||
test-integration:
|
||||
cargo test -p postgres -p postgres-federation -p postgres-search -p presentation
|
||||
|
||||
# Apply fmt + clippy auto-fixes in one shot.
|
||||
fix:
|
||||
cargo fmt
|
||||
cargo clippy --fix --allow-dirty --allow-staged
|
||||
|
||||
# Start infra (Postgres + NATS) for local development.
|
||||
dev-infra:
|
||||
docker compose up postgres nats -d
|
||||
|
||||
# Stop infra.
|
||||
dev-infra-down:
|
||||
docker compose down
|
||||
|
||||
# Full Docker stack.
|
||||
up:
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: check fmt fmt-check clippy test test-unit test-integration fix dev-infra dev-infra-down up
|
||||
279
README.md
279
README.md
@@ -1,279 +0,0 @@
|
||||
# Thoughts
|
||||
|
||||
A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend.
|
||||
|
||||
## Features
|
||||
|
||||
- Short-form posts (thoughts) with replies, boosts, and likes
|
||||
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync
|
||||
- **Remote actor discovery** — search by `@user@instance` handle, view full remote profiles (bio, banner, profile fields, posts, followers, following tabs), follow from within the UI
|
||||
- **Worker-backed remote caches** — remote posts and follower/following lists are fetched by the NATS worker and cached locally; profiles populate on first visit and refresh in the background
|
||||
- Content negotiation at `GET /users/{username}` — serves ActivityPub actor JSON or REST profile based on `Accept` header
|
||||
- Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes
|
||||
- Async event fan-out via NATS JetStream — notifications and AP delivery run in a separate worker process; pull consumer with 1-hour TTL caching
|
||||
- JWT authentication (Bearer token) with API key support for third-party clients
|
||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||
- **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment
|
||||
- **Custom CSS** — per-user stylesheet applied to their profile page
|
||||
- **Visibility levels** — public, followers-only, unlisted, and direct posts
|
||||
- **Content warnings** — optional CW label and sensitive flag on posts
|
||||
- **Feed controls** — sort by newest, oldest, most liked, most boosted, or most discussed; filter to originals only, replies only, local only, or hide sensitive
|
||||
- **Popular tags** — trending hashtag discovery
|
||||
- Top friends — pin up to 8 users as highlighted contacts
|
||||
- Account migration — set `alsoKnownAs` for Fediverse actor moves
|
||||
- Home feed, public feed, and per-user thought timelines
|
||||
- Rate limiting and registration control
|
||||
|
||||
## Federation
|
||||
|
||||
Thoughts implements the [ActivityPub](https://www.w3.org/TR/activitypub/) protocol, making it compatible with Mastodon, Misskey, Pleroma, and other Fediverse software.
|
||||
|
||||
### Fediverse endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /.well-known/webfinger` | WebFinger discovery (`?resource=acct:user@host`) |
|
||||
| `GET /.well-known/nodeinfo` | NodeInfo pointer |
|
||||
| `GET /nodeinfo/2.0` | NodeInfo 2.0 — software metadata |
|
||||
| `GET /users/{username}` | Actor profile (content-negotiated: JSON-LD or REST) |
|
||||
| `GET /users/{username}/outbox` | Paginated outbox of `Note` activities |
|
||||
| `POST /users/{username}/inbox` | Per-actor inbox |
|
||||
| `POST /inbox` | Shared inbox for bulk delivery |
|
||||
|
||||
### Federation flow
|
||||
|
||||
1. A remote user follows `@you@yourinstance.com` → Mastodon sends a `Follow` activity to `/users/you/inbox`
|
||||
2. Thoughts accepts and delivers an `Accept` back to the remote actor's inbox
|
||||
3. When you post, Thoughts fans out a `Create(Note)` activity to all remote followers via the NATS worker
|
||||
4. Remote posts from people you follow are fetched, cached, and shown in your home feed
|
||||
|
||||
### Without NATS
|
||||
|
||||
Federation still works without NATS — activities are processed in-process synchronously. The worker is required for async fan-out delivery to remote servers at scale. See [Environment Variables](#environment-variables).
|
||||
|
||||
### Instance moderation
|
||||
|
||||
- **Domain blocks** — block an entire instance; no activities are delivered to or accepted from blocked domains
|
||||
- **Actor blocks** — block individual remote actors; a `Block` activity is delivered and they are filtered from all feeds
|
||||
|
||||
## Architecture
|
||||
|
||||
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||
|
||||
```
|
||||
domain — pure types and port trait definitions, no external deps
|
||||
application — use cases and event processing services (business logic)
|
||||
api-types — shared REST API request/response DTOs
|
||||
presentation — Axum HTTP router, OpenAPI spec, composition root for the API process
|
||||
bootstrap — binary: thoughts (API server)
|
||||
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
||||
adapters/
|
||||
auth — JWT issuance and validation, Argon2 password hashing
|
||||
storage — object storage adapter (local filesystem + S3/MinIO) implementing the MediaStore port
|
||||
postgres — PostgreSQL repositories for all domain entities
|
||||
postgres-search — PostgreSQL trigram full-text search
|
||||
postgres-federation — PostgreSQL-backed federation repository
|
||||
k-ap (external) — generic AP protocol layer (ActivityPubService, actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, HTTP signatures)
|
||||
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
|
||||
nats — NATS transport implementing Transport + MessageSource ports
|
||||
event-payload — shared event serialization DTOs
|
||||
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
|
||||
```
|
||||
|
||||
The `domain` and `application` crates have zero concrete adapter dependencies. All I/O goes through `&dyn Port` traits, keeping business logic fully testable with in-memory fakes.
|
||||
|
||||
## Media Storage
|
||||
|
||||
Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /users/me/banner` (multipart/form-data). Uploaded images are served at `GET /media/*path` (public, no auth required). Set `STORAGE_BACKEND` to configure the backend.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust stable (1.80+)
|
||||
- PostgreSQL 15+
|
||||
- NATS with JetStream (optional — see [Without NATS](#without-nats))
|
||||
- Docker & Docker Compose (for the easiest local setup)
|
||||
|
||||
### Private cargo registry
|
||||
|
||||
The `k-ap` crate (ActivityPub protocol library) is hosted on a private Gitea registry configured in `.cargo/config.toml`. To build the project you need read access to `git.gabrielkaszewski.dev`. If you're contributing and don't have access, open an issue and I'll sort it out.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your values.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Secret used to sign JWT tokens — use a long random string in production |
|
||||
| `BASE_URL` | Public URL of the API server — used for ActivityPub actor URLs and canonical links |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HOST` | `0.0.0.0` | Interface to bind |
|
||||
| `PORT` | `8000` | Port to listen on |
|
||||
| `NATS_URL` | — | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
|
||||
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
|
||||
| `RATE_LIMIT` | disabled | Max requests per minute per IP |
|
||||
| `ALLOW_REGISTRATION` | `true` | Set to `false` to close sign-ups |
|
||||
| `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging |
|
||||
| `RUST_LOG` | `info` | Log level filter (`error`, `warn`, `info`, `debug`, `trace`) |
|
||||
| `STORAGE_BACKEND` | `local` | Storage backend: `local` or `s3` |
|
||||
| `STORAGE_PATH` | — | Local filesystem path for media (required when `STORAGE_BACKEND=local`) |
|
||||
| `STORAGE_PREFIX` | — | Optional key prefix for all stored objects |
|
||||
| `S3_ENDPOINT` | — | S3/MinIO endpoint URL (required when `STORAGE_BACKEND=s3`) |
|
||||
| `S3_ACCESS_KEY_ID` | — | S3 access key (required when `STORAGE_BACKEND=s3`) |
|
||||
| `S3_SECRET_ACCESS_KEY` | — | S3 secret key (required when `STORAGE_BACKEND=s3`) |
|
||||
| `S3_BUCKET` | — | S3 bucket name (required when `STORAGE_BACKEND=s3`) |
|
||||
| `S3_REGION` | `us-east-1` | S3 region |
|
||||
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) |
|
||||
| `UPLOAD_ALLOWED_TYPES` | `image/jpeg,image/png,image/gif,image/webp,image/avif` | Comma-separated allowed MIME types |
|
||||
|
||||
### Frontend environment
|
||||
|
||||
Copy `thoughts-frontend/.env.example` to `thoughts-frontend/.env.local` and adjust:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_API_URL` | API URL for client-side (browser) requests, e.g. `http://localhost:8000` |
|
||||
| `NEXT_PUBLIC_SERVER_SIDE_API_URL` | API URL for SSR requests — same as above locally, or `http://api:8000` inside Docker |
|
||||
| `NEXT_PUBLIC_FEDIVERSE_DOMAIN` | (Optional) Domain shown on profile fediverse handles, e.g. `yourinstance.example.com` |
|
||||
|
||||
## Run
|
||||
|
||||
### Local development (recommended)
|
||||
|
||||
Start only the infrastructure containers (Postgres + NATS), then run the Rust backend and Next.js frontend natively for fast iteration:
|
||||
|
||||
```bash
|
||||
# 1. Start Postgres + NATS
|
||||
make dev-infra
|
||||
|
||||
# 2. Copy and fill in env files
|
||||
cp .env.example .env
|
||||
cp thoughts-frontend/.env.example thoughts-frontend/.env.local
|
||||
|
||||
# 3. API server (runs migrations automatically on startup)
|
||||
cargo run -p bootstrap
|
||||
|
||||
# 4. Event worker (separate terminal, optional)
|
||||
cargo run -p worker
|
||||
|
||||
# 5. Frontend (separate terminal)
|
||||
cd thoughts-frontend && bun install && bun dev
|
||||
```
|
||||
|
||||
### Bare metal
|
||||
|
||||
```bash
|
||||
# API server (runs migrations automatically on startup)
|
||||
cargo run -p bootstrap
|
||||
|
||||
# Event worker — federation fan-out and notifications (separate terminal)
|
||||
cargo run -p worker
|
||||
```
|
||||
|
||||
Both processes share the same PostgreSQL database. The worker is optional but required for ActivityPub delivery to remote servers.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# Unit tests only — no database required
|
||||
make test-unit
|
||||
|
||||
# Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
|
||||
make test-integration
|
||||
|
||||
# Everything (unit + integration)
|
||||
make test
|
||||
|
||||
# Full check suite: fmt + clippy + tests
|
||||
make check
|
||||
```
|
||||
|
||||
`make test-unit` runs domain, application, api-types, and activitypub tests using in-memory fakes — the fastest feedback loop for business logic. `make test-integration` runs the adapter crates against a live PostgreSQL.
|
||||
|
||||
## API
|
||||
|
||||
All REST endpoints are under the root path. Authentication uses `Authorization: Bearer <token>` obtained from `POST /auth/login`.
|
||||
|
||||
Interactive API documentation is available at runtime:
|
||||
|
||||
- **Swagger UI** — `http://localhost:8000/docs`
|
||||
- **Scalar** — `http://localhost:8000/scalar`
|
||||
|
||||
## Frontend
|
||||
|
||||
The Next.js frontend lives in `thoughts-frontend/`. See [Frontend environment](#frontend-environment) for required env vars, or follow the [local development](#local-development-recommended) steps above.
|
||||
|
||||
## Docker
|
||||
|
||||
The backend image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers:
|
||||
|
||||
```bash
|
||||
docker build -t thoughts .
|
||||
|
||||
# API server
|
||||
docker run -p 8000:8000 \
|
||||
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||
-e JWT_SECRET=change-me \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
-e STORAGE_BACKEND=local \
|
||||
-e STORAGE_PATH=/data/media \
|
||||
-v media_vol:/data/media \
|
||||
thoughts
|
||||
|
||||
# Event worker (same image, different entrypoint)
|
||||
docker run \
|
||||
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
--entrypoint ./thoughts-worker \
|
||||
thoughts
|
||||
|
||||
# Frontend
|
||||
docker build -t thoughts-frontend \
|
||||
--build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \
|
||||
--build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \
|
||||
thoughts-frontend/
|
||||
docker run -p 3000:3000 thoughts-frontend
|
||||
```
|
||||
|
||||
### Full Docker stack
|
||||
|
||||
`compose.yml` spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
|
||||
|
||||
```bash
|
||||
make up # or: docker compose up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
| Service | Port | Description |
|
||||
|---|---|---|
|
||||
| `postgres` | 5432 | PostgreSQL 16 |
|
||||
| `nats` | 4222 / 8222 | NATS with JetStream; 8222 is the monitoring endpoint |
|
||||
| `api` | 8000 | Thoughts API server |
|
||||
| `worker` | — | Event worker (no exposed port) |
|
||||
| `frontend` | 3000 | Next.js frontend |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. A few guidelines:
|
||||
|
||||
- **Run tests before opening a PR.** At minimum: `make test-unit` (no database needed). For adapter changes: `make test-integration` with a live database. `make check` runs the full suite (fmt + clippy + tests).
|
||||
- **Keep the hexagonal boundary.** `domain` and `application` must not import any adapter crate. Use `&dyn Port` traits for all I/O.
|
||||
- **No ORM.** The project uses raw `sqlx`. Keep it that way.
|
||||
- **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).
|
||||
- **Small, focused PRs** are easier to review than large ones.
|
||||
|
||||
For significant changes, open an issue first to discuss the approach.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE).
|
||||
2
codebase-prompt.txt
Normal file
2
codebase-prompt.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
uvx files-to-prompt thoughts-backend -e toml -e rs -e md --ignore "*target" -o backend-codebase.txt
|
||||
uvx files-to-prompt thoughts-frontend -o frontend-codebase.txt --ignore "*node_modules" --ignore "*.lock"
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:15-alpine
|
||||
container_name: thoughts-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -17,21 +17,19 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
api:
|
||||
container_name: thoughts-api
|
||||
image: registry.gabrielkaszewski.dev/thoughts:latest
|
||||
backend:
|
||||
container_name: thoughts-backend
|
||||
image: thoughts-backend:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
RUST_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8000
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
BASE_URL: ${BASE_URL}
|
||||
NATS_URL: nats://k_nats:4222
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-*}
|
||||
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
|
||||
- RUST_LOG=info
|
||||
- RUST_BACKTRACE=1
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- PREFORK=1
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
- BASE_URL=https://thoughts.gabrielkaszewski.dev
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
@@ -42,59 +40,34 @@ services:
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
- shared-services
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
# Original API subdomain — keep for backwards compat and direct API access
|
||||
- "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
|
||||
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.thoughts-api.service=thoughts-api"
|
||||
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
|
||||
# Federation routes on the main domain — higher priority than the frontend catch-all
|
||||
- "traefik.http.routers.thoughts-federation.rule=Host(`thoughts.gabrielkaszewski.dev`) && (PathPrefix(`/.well-known`) || PathPrefix(`/nodeinfo`) || Path(`/inbox`) || (Method(`POST`) && PathPrefix(`/users/`)))"
|
||||
- "traefik.http.routers.thoughts-federation.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.thoughts-federation.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.thoughts-federation.service=thoughts-api"
|
||||
- "traefik.http.routers.thoughts-federation.priority=1000"
|
||||
|
||||
worker:
|
||||
container_name: thoughts-worker
|
||||
image: registry.gabrielkaszewski.dev/thoughts:latest
|
||||
entrypoint: ["./thoughts-worker"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
|
||||
BASE_URL: ${BASE_URL}
|
||||
NATS_URL: nats://k_nats:4222
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
- shared-services
|
||||
|
||||
frontend:
|
||||
container_name: thoughts-frontend
|
||||
image: registry.gabrielkaszewski.dev/thoughts-frontend:latest
|
||||
image: thoughts-frontend:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
||||
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
|
||||
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
environment:
|
||||
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
||||
- PORT=3000
|
||||
- HOSTNAME=0.0.0.0
|
||||
networks:
|
||||
- internal
|
||||
|
||||
proxy:
|
||||
container_name: thoughts-proxy
|
||||
image: custom-proxy:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
- traefik
|
||||
@@ -105,16 +78,14 @@ services:
|
||||
- "traefik.http.routers.thoughts.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.thoughts.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.thoughts.service=thoughts"
|
||||
- "traefik.http.services.thoughts.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.thoughts.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
shared-services:
|
||||
external: true
|
||||
traefik:
|
||||
name: traefik
|
||||
external: true
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
110
compose.yml
110
compose.yml
@@ -1,73 +1,77 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
container_name: thoughts-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: thoughts
|
||||
ports:
|
||||
- "5432:5432"
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
nats:
|
||||
image: nats:2-alpine
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222" # monitoring endpoint
|
||||
command: ["--jetstream", "--http_port", "8222"]
|
||||
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
backend:
|
||||
container_name: thoughts-backend
|
||||
build:
|
||||
context: ./thoughts-backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
||||
JWT_SECRET: change-me-in-production
|
||||
BASE_URL: http://localhost:8000
|
||||
PORT: 8000
|
||||
NATS_URL: nats://nats:4222
|
||||
RUST_LOG: info
|
||||
STORAGE_BACKEND: local
|
||||
STORAGE_PATH: /data/media
|
||||
volumes:
|
||||
- media_data:/data/media
|
||||
- RUST_LOG=info
|
||||
- RUST_BACKTRACE=1
|
||||
depends_on:
|
||||
postgres:
|
||||
database:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
|
||||
worker:
|
||||
build: .
|
||||
entrypoint: ["./thoughts-worker"]
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
||||
BASE_URL: http://localhost:8000
|
||||
NATS_URL: nats://nats:4222
|
||||
RUST_LOG: info
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
container_name: thoughts-frontend
|
||||
build:
|
||||
context: ./thoughts-frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
NEXT_PUBLIC_API_URL: http://localhost/api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
- backend
|
||||
environment:
|
||||
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
||||
|
||||
proxy:
|
||||
container_name: thoughts-proxy
|
||||
image: nginx:stable-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
|
||||
db_test:
|
||||
image: postgres:15-alpine
|
||||
container_name: thoughts-db-test
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "5434:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
media_data:
|
||||
driver: local
|
||||
|
||||
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[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"
|
||||
615
crates/adapters/activitypub-base/src/activities.rs
Normal file
615
crates/adapters/activitypub-base/src/activities.rs
Normal file
@@ -0,0 +1,615 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
kinds::activity::{
|
||||
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
|
||||
},
|
||||
traits::Activity,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Announce")]
|
||||
pub struct AnnounceType;
|
||||
|
||||
use crate::actors::DbActor;
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::{FollowerStatus, FollowingStatus};
|
||||
|
||||
// --- Follow ---
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FollowActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: FollowType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: ObjectId<DbActor>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for FollowActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let target_url = self.object.inner();
|
||||
let target_domain = match (target_url.host_str(), target_url.port()) {
|
||||
(Some(host), Some(port)) => format!("{}:{}", host, port),
|
||||
(Some(host), None) => host.to_string(),
|
||||
_ => {
|
||||
return Err(Error::bad_request(anyhow::anyhow!(
|
||||
"invalid follow target URL"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if target_domain != data.domain {
|
||||
return Err(Error::bad_request(anyhow::anyhow!(
|
||||
"follow target is not a local actor"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let _follower = self.actor.dereference(data).await?;
|
||||
let local_actor = self.object.dereference(data).await?;
|
||||
|
||||
if data
|
||||
.federation_repo
|
||||
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
|
||||
.await?
|
||||
{
|
||||
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
data.federation_repo
|
||||
.add_follower(
|
||||
local_actor.user_id,
|
||||
self.actor.inner().as_str(),
|
||||
FollowerStatus::Pending,
|
||||
self.id.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
follower = %self.actor.inner(),
|
||||
local_user = %local_actor.user_id,
|
||||
"follow request pending approval"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Accept ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcceptActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AcceptType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AcceptActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
|
||||
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
|
||||
data.federation_repo
|
||||
.update_following_status(
|
||||
local_user_id,
|
||||
self.actor.inner().as_str(),
|
||||
FollowingStatus::Accepted,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reject ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RejectActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: RejectType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for RejectActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
|
||||
data.federation_repo
|
||||
.remove_following(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "follow rejected");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undo ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UndoActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: UndoType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for UndoActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let obj_type = self
|
||||
.object
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match obj_type {
|
||||
"Follow" => {
|
||||
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) {
|
||||
if let Ok(url) = Url::parse(obj_url) {
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(&url) {
|
||||
data.federation_repo
|
||||
.remove_follower(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
data.object_handler
|
||||
.on_actor_removed(self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||
}
|
||||
"Add" => {
|
||||
let ap_id_str = self
|
||||
.object
|
||||
.get("object")
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
|
||||
|
||||
if let Some(ap_id_str) = ap_id_str {
|
||||
if let Ok(ap_id) = Url::parse(ap_id_str) {
|
||||
data.object_handler
|
||||
.on_delete(&ap_id, self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: CreateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for CreateActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_create(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received create activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: DeleteType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for DeleteActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_delete(&self.object, &actor_url)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(object = %self.object, "received delete activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: UpdateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for UpdateActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_update(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received update activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Announce ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnnounceActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AnnounceType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AnnounceActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let object_domain = self.object.host_str().unwrap_or("");
|
||||
if object_domain != data.domain {
|
||||
return Ok(());
|
||||
}
|
||||
data.federation_repo
|
||||
.add_announce(
|
||||
self.id.as_str(),
|
||||
self.object.as_str(),
|
||||
self.actor.inner().as_str(),
|
||||
self.published.unwrap_or_else(chrono::Utc::now),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Add")]
|
||||
pub struct AddType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AddType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AddActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_create(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received Add activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Block")]
|
||||
pub struct BlockType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: BlockType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for BlockActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
// They blocked us — remove them from our following list
|
||||
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
|
||||
let _ = data
|
||||
.federation_repo
|
||||
.remove_following(local_user_id, self.actor.inner().as_str())
|
||||
.await;
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "received block");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox dispatch enum ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[enum_delegate::implement(Activity)]
|
||||
pub enum InboxActivities {
|
||||
#[serde(rename = "Follow")]
|
||||
Follow(FollowActivity),
|
||||
#[serde(rename = "Accept")]
|
||||
Accept(AcceptActivity),
|
||||
#[serde(rename = "Reject")]
|
||||
Reject(RejectActivity),
|
||||
#[serde(rename = "Undo")]
|
||||
Undo(UndoActivity),
|
||||
#[serde(rename = "Create")]
|
||||
Create(CreateActivity),
|
||||
#[serde(rename = "Delete")]
|
||||
Delete(DeleteActivity),
|
||||
#[serde(rename = "Update")]
|
||||
Update(UpdateActivity),
|
||||
#[serde(rename = "Announce")]
|
||||
Announce(AnnounceActivity),
|
||||
#[serde(rename = "Add")]
|
||||
Add(AddActivity),
|
||||
#[serde(rename = "Block")]
|
||||
Block(BlockActivity),
|
||||
}
|
||||
25
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
25
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use activitypub_federation::{
|
||||
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
||||
};
|
||||
use axum::extract::Path;
|
||||
|
||||
use crate::actors::{Person, get_local_actor};
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
pub async fn actor_handler(
|
||||
Path(username): Path<String>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
||||
let ap_user = data
|
||||
.user_repo
|
||||
.find_by_username(&username)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let db_actor = get_local_actor(ap_user.id, &data).await?;
|
||||
let person = db_actor.into_json(&data).await?;
|
||||
|
||||
Ok(FederationJson(WithContext::new_default(person)))
|
||||
}
|
||||
327
crates/adapters/activitypub-base/src/actors.rs
Normal file
327
crates/adapters/activitypub-base/src/actors.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
http_signatures::generate_actor_keypair,
|
||||
kinds::actor::PersonType,
|
||||
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
||||
traits::{Actor, Object},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::RemoteActor;
|
||||
use crate::user::ApProfileField;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbActor {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub public_key_pem: String,
|
||||
pub private_key_pem: Option<String>,
|
||||
pub inbox_url: Url,
|
||||
pub outbox_url: Url,
|
||||
pub followers_url: Url,
|
||||
pub following_url: Url,
|
||||
pub ap_id: Url,
|
||||
pub last_refreshed_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub banner_url: Option<Url>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub profile_url: Option<Url>,
|
||||
pub attachment: Vec<ApProfileField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApImageObject {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Endpoints {
|
||||
pub shared_inbox: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileFieldObject {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
#[serde(rename = "type")]
|
||||
kind: PersonType,
|
||||
id: ObjectId<DbActor>,
|
||||
preferred_username: String,
|
||||
inbox: Url,
|
||||
outbox: Url,
|
||||
followers: Url,
|
||||
following: Url,
|
||||
public_key: PublicKey,
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<ApImageObject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
discoverable: Option<bool>,
|
||||
manually_approves_followers: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
updated: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
endpoints: Option<Endpoints>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
image: Option<ApImageObject>,
|
||||
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
||||
also_known_as: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
attachment: Vec<ProfileFieldObject>,
|
||||
}
|
||||
|
||||
pub async fn get_local_actor(
|
||||
user_id: uuid::Uuid,
|
||||
data: &Data<FederationData>,
|
||||
) -> Result<DbActor, Error> {
|
||||
let user = data
|
||||
.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
||||
|
||||
let (public_key, private_key) = match data
|
||||
.federation_repo
|
||||
.get_local_actor_keypair(user_id)
|
||||
.await?
|
||||
{
|
||||
Some(kp) => kp,
|
||||
None => {
|
||||
let kp = generate_actor_keypair()?;
|
||||
data.federation_repo
|
||||
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
||||
.await?;
|
||||
(kp.public_key, kp.private_key)
|
||||
}
|
||||
};
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
|
||||
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
|
||||
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
|
||||
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
|
||||
|
||||
Ok(DbActor {
|
||||
user_id,
|
||||
username: user.username,
|
||||
public_key_pem: public_key,
|
||||
private_key_pem: Some(private_key),
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
also_known_as: user.also_known_as,
|
||||
profile_url: user.profile_url,
|
||||
attachment: user.attachment,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Object for DbActor {
|
||||
type DataType = FederationData;
|
||||
type Kind = Person;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.ap_id
|
||||
}
|
||||
|
||||
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.last_refreshed_at)
|
||||
}
|
||||
|
||||
async fn read_from_id(
|
||||
object_id: Url,
|
||||
data: &Data<Self::DataType>,
|
||||
) -> Result<Option<Self>, Self::Error> {
|
||||
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let user = match data.user_repo.find_by_id(user_id).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let keypair = data
|
||||
.federation_repo
|
||||
.get_local_actor_keypair(user_id)
|
||||
.await?;
|
||||
|
||||
let (public_key, private_key) = match keypair {
|
||||
Some(kp) => (kp.0, Some(kp.1)),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
|
||||
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
|
||||
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
|
||||
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
|
||||
|
||||
Ok(Some(DbActor {
|
||||
user_id,
|
||||
username: user.username,
|
||||
public_key_pem: public_key,
|
||||
private_key_pem: private_key,
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||
let public_key = PublicKey {
|
||||
id: format!("{}#main-key", &self.ap_id),
|
||||
owner: self.ap_id.clone(),
|
||||
public_key_pem: self.public_key_pem.clone(),
|
||||
};
|
||||
|
||||
let icon = self.avatar_url.map(|url| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url,
|
||||
});
|
||||
let image = self.banner_url.map(|url| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url,
|
||||
});
|
||||
let profile_url = self.profile_url;
|
||||
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
||||
let attachment: Vec<ProfileFieldObject> = self
|
||||
.attachment
|
||||
.into_iter()
|
||||
.map(|f| ProfileFieldObject {
|
||||
kind: "PropertyValue".to_string(),
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let shared_inbox =
|
||||
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
id: self.ap_id.clone().into(),
|
||||
preferred_username: self.username.clone(),
|
||||
inbox: self.inbox_url.clone(),
|
||||
outbox: self.outbox_url.clone(),
|
||||
followers: self.followers_url.clone(),
|
||||
following: self.following_url.clone(),
|
||||
public_key,
|
||||
name: Some(self.username.clone()),
|
||||
summary: self.bio.clone(),
|
||||
icon,
|
||||
url: profile_url,
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: true,
|
||||
updated: Some(self.last_refreshed_at),
|
||||
endpoints: Some(Endpoints { shared_inbox }),
|
||||
image,
|
||||
also_known_as,
|
||||
attachment,
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
json: &Self::Kind,
|
||||
expected_domain: &Url,
|
||||
_data: &Data<Self::DataType>,
|
||||
) -> Result<(), Self::Error> {
|
||||
verify_domains_match(json.id.inner(), expected_domain)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||
let actor = RemoteActor {
|
||||
url: json.id.inner().to_string(),
|
||||
handle: json.preferred_username.clone(),
|
||||
inbox_url: json.inbox.to_string(),
|
||||
shared_inbox_url: None,
|
||||
display_name: json.name.clone(),
|
||||
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
||||
outbox_url: Some(json.outbox.to_string()),
|
||||
};
|
||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||
|
||||
let url_str = json.id.inner().to_string();
|
||||
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
||||
let ap_id = json.id.inner().clone();
|
||||
let inbox_url = json.inbox.clone();
|
||||
let outbox_url = json.outbox.clone();
|
||||
let followers_url = json.followers.clone();
|
||||
let following_url = json.following.clone();
|
||||
|
||||
Ok(DbActor {
|
||||
user_id,
|
||||
username: json.preferred_username.clone(),
|
||||
public_key_pem: json.public_key.public_key_pem,
|
||||
private_key_pem: None,
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DbActor {
|
||||
fn public_key_pem(&self) -> &str {
|
||||
&self.public_key_pem
|
||||
}
|
||||
|
||||
fn private_key_pem(&self) -> Option<String> {
|
||||
self.private_key_pem.clone()
|
||||
}
|
||||
|
||||
fn inbox(&self) -> Url {
|
||||
self.inbox_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/actors.rs"]
|
||||
mod tests;
|
||||
47
crates/adapters/activitypub-base/src/content.rs
Normal file
47
crates/adapters/activitypub-base/src/content.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApObjectHandler: Send + Sync {
|
||||
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
||||
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
||||
|
||||
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
||||
/// Returns (ap_id, object_json, published_at).
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
||||
|
||||
/// Incoming Create activity — persist remote content.
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Incoming Update activity — update existing remote content.
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Incoming Delete activity — remove specific remote content.
|
||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Actor unfollowed/was removed — clean up all their remote content.
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Total number of locally-authored posts across all users.
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
||||
}
|
||||
48
crates/adapters/activitypub-base/src/data.rs
Normal file
48
crates/adapters/activitypub-base/src/data.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::content::ApObjectHandler;
|
||||
use crate::repository::FederationRepository;
|
||||
use crate::user::ApUserRepository;
|
||||
use domain::ports::EventPublisher;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FederationData {
|
||||
pub(crate) federation_repo: Arc<dyn FederationRepository>,
|
||||
pub(crate) user_repo: Arc<dyn ApUserRepository>,
|
||||
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) domain: String,
|
||||
pub(crate) allow_registration: bool,
|
||||
pub(crate) software_name: String,
|
||||
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
}
|
||||
|
||||
impl FederationData {
|
||||
pub fn new(
|
||||
federation_repo: Arc<dyn FederationRepository>,
|
||||
user_repo: Arc<dyn ApUserRepository>,
|
||||
object_handler: Arc<dyn ApObjectHandler>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
software_name: String,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
) -> Self {
|
||||
let domain = base_url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Self {
|
||||
federation_repo,
|
||||
user_repo,
|
||||
object_handler,
|
||||
base_url,
|
||||
domain,
|
||||
allow_registration,
|
||||
software_name,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
48
crates/adapters/activitypub-base/src/error.rs
Normal file
48
crates/adapters/activitypub-base/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
||||
|
||||
impl Error {
|
||||
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
||||
Self(e.into(), StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
||||
Self(e.into(), StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for Error
|
||||
where
|
||||
T: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(t: T) -> Self {
|
||||
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::response::IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let status = self.1;
|
||||
if status.is_server_error() {
|
||||
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
|
||||
} else {
|
||||
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
||||
}
|
||||
let body = if status.is_server_error() {
|
||||
"internal server error".to_string()
|
||||
} else {
|
||||
self.0.to_string()
|
||||
};
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
|
||||
use activitypub_federation::error::Error as FedError;
|
||||
use url::Url;
|
||||
|
||||
use crate::data::FederationData;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PermissiveVerifier;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UrlVerifier for PermissiveVerifier {
|
||||
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
|
||||
|
||||
impl ApFederationConfig {
|
||||
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
||||
let config = if debug {
|
||||
FederationConfig::builder()
|
||||
.domain(&data.domain)
|
||||
.app_data(data)
|
||||
.debug(true)
|
||||
.http_signature_compat(true)
|
||||
.url_verifier(Box::new(PermissiveVerifier))
|
||||
.build()
|
||||
.await?
|
||||
} else {
|
||||
FederationConfig::builder()
|
||||
.domain(&data.domain)
|
||||
.app_data(data)
|
||||
.debug(false)
|
||||
.http_signature_compat(true)
|
||||
.build()
|
||||
.await?
|
||||
};
|
||||
Ok(Self(config))
|
||||
}
|
||||
|
||||
pub fn to_request_data(&self) -> Data<FederationData> {
|
||||
self.0.to_request_data()
|
||||
}
|
||||
|
||||
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
||||
FederationMiddleware::new(self.0.clone())
|
||||
}
|
||||
}
|
||||
130
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
130
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||
use axum::extract::{Path, Query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
const PAGE_SIZE: usize = 20;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PageQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn followers_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<PageQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let collection_id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
if let Some(page) = query.page {
|
||||
let page = page.max(1);
|
||||
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||
let followers = data
|
||||
.federation_repo
|
||||
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let has_next = offset + followers.len() < total;
|
||||
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
|
||||
|
||||
let mut obj = json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, page),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
|
||||
if has_next {
|
||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||
}
|
||||
|
||||
Ok(FederationJson(obj))
|
||||
} else {
|
||||
Ok(FederationJson(json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn following_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<PageQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let collection_id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
if let Some(page) = query.page {
|
||||
let page = page.max(1);
|
||||
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||
let following = data
|
||||
.federation_repo
|
||||
.get_following_page(user_id, offset as u32, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let has_next = offset + following.len() < total;
|
||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||
|
||||
let mut obj = json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, page),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
|
||||
if has_next {
|
||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||
}
|
||||
|
||||
Ok(FederationJson(obj))
|
||||
} else {
|
||||
Ok(FederationJson(json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})))
|
||||
}
|
||||
}
|
||||
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use activitypub_federation::{
|
||||
axum::inbox::{ActivityData, receive_activity},
|
||||
config::Data,
|
||||
protocol::context::WithContext,
|
||||
};
|
||||
|
||||
use crate::activities::InboxActivities;
|
||||
use crate::actors::DbActor;
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
pub async fn inbox_handler(
|
||||
data: Data<FederationData>,
|
||||
activity_data: ActivityData,
|
||||
) -> Result<(), Error> {
|
||||
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
||||
.await
|
||||
}
|
||||
27
crates/adapters/activitypub-base/src/lib.rs
Normal file
27
crates/adapters/activitypub-base/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
pub mod activities;
|
||||
pub mod actor_handler;
|
||||
pub mod actors;
|
||||
pub mod content;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod followers_handler;
|
||||
pub mod inbox;
|
||||
pub mod nodeinfo;
|
||||
pub mod outbox;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub(crate) mod urls;
|
||||
pub use urls::AS_PUBLIC;
|
||||
pub mod user;
|
||||
pub mod webfinger;
|
||||
|
||||
pub use content::ApObjectHandler;
|
||||
pub use data::FederationData;
|
||||
pub use error::Error;
|
||||
pub use federation::ApFederationConfig;
|
||||
pub use repository::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
pub use service::ActivityPubService;
|
||||
pub use user::{ApProfileField, ApUser, ApUserRepository};
|
||||
80
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
80
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use activitypub_federation::config::Data;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoWellKnown {
|
||||
pub links: Vec<NodeInfoLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoLink {
|
||||
pub rel: String,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoSoftware {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfoUsage {
|
||||
pub users: NodeInfoUsers,
|
||||
pub local_posts: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoUsers {
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfo {
|
||||
pub version: String,
|
||||
pub software: NodeInfoSoftware,
|
||||
pub protocols: Vec<String>,
|
||||
pub usage: NodeInfoUsage,
|
||||
pub open_registrations: bool,
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_well_known_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfoWellKnown>, Error> {
|
||||
let href = format!("{}/nodeinfo/2.0", data.base_url);
|
||||
Ok(Json(NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href,
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
|
||||
let user_count = data.user_repo.count_users().await.unwrap_or(0);
|
||||
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
|
||||
|
||||
Ok(Json(NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: data.software_name.clone(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: user_count },
|
||||
local_posts,
|
||||
},
|
||||
open_registrations: data.allow_registration,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/nodeinfo.rs"]
|
||||
mod tests;
|
||||
138
crates/adapters/activitypub-base/src/outbox.rs
Normal file
138
crates/adapters/activitypub-base/src/outbox.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use activitypub_federation::{
|
||||
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
||||
protocol::context::WithContext,
|
||||
};
|
||||
|
||||
use crate::{activities::CreateActivity, data::FederationData, error::Error};
|
||||
|
||||
const PAGE_SIZE: usize = 20;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OutboxQuery {
|
||||
page: Option<bool>,
|
||||
before: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrderedCollection {
|
||||
#[serde(rename = "@context")]
|
||||
context: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
id: String,
|
||||
total_items: u64,
|
||||
first: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrderedCollectionPage {
|
||||
#[serde(rename = "@context")]
|
||||
context: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
id: String,
|
||||
part_of: String,
|
||||
ordered_items: Vec<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn outbox_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<OutboxQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<axum::response::Response, Error> {
|
||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(uuid)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
||||
|
||||
if query.page.unwrap_or(false) {
|
||||
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
||||
|
||||
let items = data
|
||||
.object_handler
|
||||
.get_local_objects_page(uuid, before, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
|
||||
|
||||
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
|
||||
.parse()
|
||||
.expect("valid url");
|
||||
|
||||
let has_more = items.len() == PAGE_SIZE;
|
||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
||||
|
||||
let followers_url = format!("{}/followers", actor_url);
|
||||
let ordered_items: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|(ap_id, object, _)| {
|
||||
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||
serde_json::to_value(WithContext::new_default(CreateActivity {
|
||||
id: create_id,
|
||||
kind: CreateType::default(),
|
||||
actor: ObjectId::from(actor_url.clone()),
|
||||
object,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![followers_url.clone()],
|
||||
}))
|
||||
.expect("serializable")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let page_id = match &query.before {
|
||||
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
|
||||
None => format!("{}?page=true", outbox_url),
|
||||
};
|
||||
|
||||
let next = if has_more {
|
||||
oldest_ts.map(|ts| {
|
||||
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
||||
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
format!("{}?page=true&before={}", outbox_url, ts_str)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(axum::Json(OrderedCollectionPage {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
kind: "OrderedCollectionPage".to_string(),
|
||||
id: page_id,
|
||||
part_of: outbox_url,
|
||||
ordered_items,
|
||||
next,
|
||||
})
|
||||
.into_response())
|
||||
} else {
|
||||
let total = data
|
||||
.object_handler
|
||||
.get_local_objects_for_user(uuid)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
|
||||
.len() as u64;
|
||||
|
||||
Ok(axum::Json(OrderedCollection {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
kind: "OrderedCollection".to_string(),
|
||||
id: outbox_url.clone(),
|
||||
total_items: total,
|
||||
first: format!("{}?page=true", outbox_url),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
134
crates/adapters/activitypub-base/src/repository.rs
Normal file
134
crates/adapters/activitypub-base/src/repository.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FollowerStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FollowingStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteActor {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Follower {
|
||||
pub actor: RemoteActor,
|
||||
pub status: FollowerStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedDomain {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationRepository: Send + Sync {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>>;
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||
async fn get_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>>;
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||
async fn get_following_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>>;
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()>;
|
||||
async fn add_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>>;
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>>;
|
||||
async fn save_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()>;
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()>;
|
||||
async fn get_following_outbox_url(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>>;
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()>;
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize>;
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
|
||||
}
|
||||
1221
crates/adapters/activitypub-base/src/service.rs
Normal file
1221
crates/adapters/activitypub-base/src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
49
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
49
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn person_serializes_with_enriched_fields() {
|
||||
let person = Person {
|
||||
kind: Default::default(),
|
||||
id: "https://example.com/users/1"
|
||||
.parse::<url::Url>()
|
||||
.unwrap()
|
||||
.into(),
|
||||
preferred_username: "alice".to_string(),
|
||||
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
||||
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
||||
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
||||
following: "https://example.com/users/1/following".parse().unwrap(),
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/users/1#main-key".to_string(),
|
||||
owner: "https://example.com/users/1".parse().unwrap(),
|
||||
public_key_pem: "pem".to_string(),
|
||||
},
|
||||
name: Some("Alice".to_string()),
|
||||
summary: Some("Bio text".to_string()),
|
||||
icon: Some(ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
||||
}),
|
||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: true,
|
||||
updated: Some(Utc::now()),
|
||||
endpoints: Some(Endpoints {
|
||||
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
||||
}),
|
||||
image: None,
|
||||
also_known_as: vec![],
|
||||
attachment: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&person).unwrap();
|
||||
assert_eq!(json["discoverable"], true);
|
||||
assert_eq!(json["summary"], "Bio text");
|
||||
assert_eq!(json["icon"]["type"], "Image");
|
||||
assert_eq!(json["manuallyApprovesFollowers"], true);
|
||||
assert!(json.get("updated").is_some());
|
||||
assert!(json.get("endpoints").is_some());
|
||||
assert_eq!(
|
||||
json["endpoints"]["sharedInbox"],
|
||||
"https://example.com/inbox"
|
||||
);
|
||||
}
|
||||
40
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
40
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_well_known_serializes_correctly() {
|
||||
let doc = NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href: "https://example.com/nodeinfo/2.0".to_string(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(
|
||||
json["links"][0]["rel"],
|
||||
"http://nodeinfo.diaspora.software/ns/schema/2.0"
|
||||
);
|
||||
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_serializes_camel_case() {
|
||||
let doc = NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: "my-app".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: 3 },
|
||||
local_posts: 42,
|
||||
},
|
||||
open_registrations: false,
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["version"], "2.0");
|
||||
assert_eq!(json["software"]["name"], "my-app");
|
||||
assert_eq!(json["usage"]["users"]["total"], 3);
|
||||
assert_eq!(json["usage"]["localPosts"], 42);
|
||||
assert_eq!(json["openRegistrations"], false);
|
||||
}
|
||||
45
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
45
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use super::*;
|
||||
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
||||
|
||||
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
url: format!("https://remote/{}", inbox),
|
||||
handle: "user".to_string(),
|
||||
inbox_url: inbox.to_string(),
|
||||
shared_inbox_url: shared.map(|s| s.to_string()),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
outbox_url: None,
|
||||
},
|
||||
status: FollowerStatus::Accepted,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_inboxes_deduplicates_shared() {
|
||||
let followers = vec![
|
||||
make_follower(
|
||||
"https://mastodon.social/users/a/inbox",
|
||||
Some("https://mastodon.social/inbox"),
|
||||
),
|
||||
make_follower(
|
||||
"https://mastodon.social/users/b/inbox",
|
||||
Some("https://mastodon.social/inbox"),
|
||||
),
|
||||
make_follower("https://other.instance/users/c/inbox", None),
|
||||
];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
assert_eq!(inboxes.len(), 2);
|
||||
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
|
||||
assert!(strs.contains(&"https://mastodon.social/inbox"));
|
||||
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_inboxes_falls_back_to_individual_inbox() {
|
||||
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
assert_eq!(inboxes.len(), 1);
|
||||
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
|
||||
}
|
||||
30
crates/adapters/activitypub-base/src/urls.rs
Normal file
30
crates/adapters/activitypub-base/src/urls.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
||||
let path = url.path();
|
||||
path.strip_prefix("/users/")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
}
|
||||
|
||||
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
|
||||
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
||||
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
|
||||
}
|
||||
|
||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
/// Extract the username segment from a /users/:username URL.
|
||||
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())
|
||||
}
|
||||
27
crates/adapters/activitypub-base/src/user.rs
Normal file
27
crates/adapters/activitypub-base/src/user.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use async_trait::async_trait;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApProfileField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApUser {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub banner_url: Option<Url>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub profile_url: Option<Url>,
|
||||
pub attachment: Vec<ApProfileField>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApUserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn count_users(&self) -> anyhow::Result<usize>;
|
||||
}
|
||||
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
||||
};
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
pub async fn webfinger_handler(
|
||||
Query(query): Query<WebfingerQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Response, Error> {
|
||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||
|
||||
let user = data
|
||||
.user_repo
|
||||
.find_by_username(name)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
||||
|
||||
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
|
||||
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
||||
}
|
||||
@@ -4,17 +4,14 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
domain = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
activitypub-base = { workspace = true }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
domain = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
const USERS_PATH_PREFIX: &str = "/users/";
|
||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||
use crate::urls::ThoughtsUrls;
|
||||
use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository};
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use domain::ports::ActivityPubRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use crate::note::ThoughtNote;
|
||||
use crate::urls::ThoughtsUrls;
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
urls: ThoughtsUrls,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
likes: Arc<dyn LikeRepository>,
|
||||
boosts: Arc<dyn BoostRepository>,
|
||||
}
|
||||
|
||||
impl ThoughtsObjectHandler {
|
||||
pub fn new(
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
base_url: &str,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
likes: Arc<dyn LikeRepository>,
|
||||
boosts: Arc<dyn BoostRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
urls: ThoughtsUrls::new(base_url),
|
||||
event_publisher,
|
||||
tag_repo,
|
||||
likes,
|
||||
boosts,
|
||||
}
|
||||
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
|
||||
Self { repo, urls: ThoughtsUrls::new(base_url) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── ApContentReader ───────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for ThoughtsObjectHandler {
|
||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self.repo.outbox_entries_for_actor(&uid).await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries.into_iter().map(|e| {
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(), actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
e.thought.created_at, in_reply_to,
|
||||
e.thought.sensitive, e.thought.content_warning, followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
@@ -54,441 +52,62 @@ impl ApContentReader 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(&user_id.to_string());
|
||||
let followers = self.urls.user_followers(&user_id.to_string());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||
id: note_url.clone(),
|
||||
actor_url,
|
||||
content: e.thought.content.as_str().to_owned(),
|
||||
published: created_at,
|
||||
in_reply_to,
|
||||
sensitive: e.thought.sensitive,
|
||||
summary: e.thought.content_warning,
|
||||
followers_url: followers,
|
||||
});
|
||||
Ok((note_url, serde_json::to_value(¬e)?, 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(¬e)?, created_at))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo
|
||||
.count_local_notes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ApObjectHandler ───────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
||||
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
|
||||
return Ok(());
|
||||
};
|
||||
let author_id = self
|
||||
.repo
|
||||
.intern_remote_actor(actor_url.as_str())
|
||||
.await
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
let author_id = self.repo.intern_remote_actor(actor_url).await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
let _ = self
|
||||
.repo
|
||||
.sync_remote_actor_to_user(actor_url.as_str())
|
||||
.await;
|
||||
|
||||
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||
let in_to = note.to.iter().any(|s| s == as_public);
|
||||
let in_cc = note.cc.iter().any(|s| s == as_public);
|
||||
let has_followers = note.to.iter().any(|s| s.ends_with("/followers"))
|
||||
|| note.cc.iter().any(|s| s.ends_with("/followers"));
|
||||
|
||||
let visibility = if in_to {
|
||||
"public"
|
||||
} else if in_cc {
|
||||
"unlisted"
|
||||
} else if has_followers {
|
||||
"followers"
|
||||
} else {
|
||||
"direct"
|
||||
};
|
||||
|
||||
let thought_id = self
|
||||
.repo
|
||||
.accept_note(AcceptNoteInput {
|
||||
ap_id: ap_id.as_str(),
|
||||
author_id: &author_id,
|
||||
content: ¬e.content,
|
||||
published: note.published,
|
||||
sensitive: note.sensitive,
|
||||
content_warning: note.summary,
|
||||
visibility,
|
||||
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||
note_extensions,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let hashtag_names: Vec<String> = note
|
||||
.tag
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag"))
|
||||
.filter_map(|t| t.get("name").and_then(|v| v.as_str()))
|
||||
.map(|name| name.trim_start_matches('#').to_lowercase())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect();
|
||||
|
||||
for name in hashtag_names {
|
||||
if let Ok(tag) = self.tag_repo.find_or_create(&name).await {
|
||||
let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
let base_url = url::Url::parse(&self.urls.base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in ¬e.tag {
|
||||
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
|
||||
continue;
|
||||
}
|
||||
let href = match tag.get("href").and_then(|h| h.as_str()) {
|
||||
Some(h) => h,
|
||||
None => continue,
|
||||
};
|
||||
let href_url = match url::Url::parse(href) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if href_url.host_str().unwrap_or("") != base_url {
|
||||
continue;
|
||||
}
|
||||
let user_uuid = href_url
|
||||
.path()
|
||||
.strip_prefix(USERS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
if let Some(uuid) = user_uuid {
|
||||
self.on_mention(ap_id, uuid, actor_url)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to process mention notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
self.repo.accept_note(
|
||||
ap_id, &author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.summary,
|
||||
).await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
_actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match obj_type {
|
||||
"Note" | "Article" | "Page" => {
|
||||
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.repo
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content, note_extensions)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
"Person" | "Service" | "Application" | "Group" | "Organization" => {
|
||||
let display_name = object.get("name").and_then(|v| v.as_str());
|
||||
let avatar_url = object
|
||||
.get("icon")
|
||||
.and_then(|v| v.get("url"))
|
||||
.and_then(|v| v.as_str());
|
||||
self.repo
|
||||
.update_remote_actor_display(
|
||||
&self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?
|
||||
.ok_or_else(|| anyhow!("unknown actor"))?,
|
||||
display_name,
|
||||
avatar_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
let _ = self
|
||||
.repo
|
||||
.sync_remote_actor_to_user(actor_url.as_str())
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(ap_id = %ap_id, obj_type, "on_update: skipping");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
self.repo.apply_note_update(ap_id, ¬e.content).await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||
self.repo
|
||||
.retract_note(ap_id.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
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.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let like_id = domain::value_objects::LikeId::new();
|
||||
|
||||
let like = domain::models::social::Like {
|
||||
id: like_id.clone(),
|
||||
user_id: actor_user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: Some(object_url.to_string()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let _ = self.likes.save(&like).await;
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::LikeAdded {
|
||||
like_id,
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_unlike(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
tracing::debug!(object = %object_url, "on_unlike: not a local thought URL, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::debug!(actor = %actor_url, "on_unlike: remote actor not interned, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let _ = self.likes.delete(&actor_user_id, &thought_id).await;
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::LikeRemoved {
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &url::Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let author_user_id = match self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_uuid = thought_ap_id
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::MentionReceived {
|
||||
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
|
||||
author_user_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let boost_id = domain::value_objects::BoostId::new();
|
||||
|
||||
let boost = domain::models::social::Boost {
|
||||
id: boost_id.clone(),
|
||||
user_id: actor_user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: Some(object_url.to_string()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let _ = self.boosts.save(&boost).await;
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::BoostAdded {
|
||||
boost_id,
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let _ = self.boosts.delete(&actor_user_id, &thought_id).await;
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::BoostRemoved {
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_of_remote(&self, _object_url: &Url, _actor_url: &Url) -> Result<()> {
|
||||
Ok(())
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,7 @@
|
||||
pub mod handler;
|
||||
pub mod note;
|
||||
pub mod port;
|
||||
pub mod service;
|
||||
pub mod urls;
|
||||
|
||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
pub use handler::ThoughtsObjectHandler;
|
||||
pub use note::ThoughtNote;
|
||||
pub use port::{
|
||||
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
|
||||
};
|
||||
pub use service::ApFederationAdapter;
|
||||
pub use urls::ThoughtsUrls;
|
||||
|
||||
use domain::ports::RemoteActorConnectionRepository;
|
||||
use k_ap::ActivityPubService;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ApServiceConfig {
|
||||
pub base_url: String,
|
||||
pub activity_repo: Arc<dyn k_ap::ActivityRepository>,
|
||||
pub follow_repo: Arc<dyn k_ap::FollowRepository>,
|
||||
pub actor_repo: Arc<dyn k_ap::ActorRepository>,
|
||||
pub blocklist_repo: Arc<dyn k_ap::BlocklistRepository>,
|
||||
pub user_repo: Arc<dyn k_ap::ApUserRepository>,
|
||||
pub ap_handler: Arc<ThoughtsObjectHandler>,
|
||||
pub connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
||||
pub event_publisher: Option<Arc<dyn k_ap::data::EventPublisher>>,
|
||||
pub allow_registration: bool,
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
pub async fn build_ap_service(
|
||||
cfg: ApServiceConfig,
|
||||
) -> (Arc<ActivityPubService>, Arc<ApFederationAdapter>) {
|
||||
let mut builder = ActivityPubService::builder(cfg.base_url)
|
||||
.activity_repo(cfg.activity_repo)
|
||||
.follow_repo(cfg.follow_repo)
|
||||
.actor_repo(cfg.actor_repo)
|
||||
.blocklist_repo(cfg.blocklist_repo)
|
||||
.user_repo(cfg.user_repo)
|
||||
.content_reader(cfg.ap_handler.clone())
|
||||
.object_handler(cfg.ap_handler)
|
||||
.allow_registration(cfg.allow_registration)
|
||||
.software_name("thoughts")
|
||||
.debug(cfg.debug)
|
||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID);
|
||||
if let Some(publisher) = cfg.event_publisher {
|
||||
builder = builder.event_publisher(publisher);
|
||||
}
|
||||
let raw = Arc::new(
|
||||
builder
|
||||
.build()
|
||||
.await
|
||||
.expect("Failed to build ActivityPubService"),
|
||||
);
|
||||
let adapter = Arc::new(ApFederationAdapter::new(raw.clone(), cfg.connections_repo));
|
||||
(raw, adapter)
|
||||
}
|
||||
|
||||
62
crates/adapters/activitypub/src/note.rs
Normal file
62
crates/adapters/activitypub/src/note.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn note_serializes_with_public_audience() {
|
||||
let note = ThoughtNote::new_public(
|
||||
"https://example.com/thoughts/1".parse().unwrap(),
|
||||
"https://example.com/users/alice".parse().unwrap(),
|
||||
"Hello world".to_string(),
|
||||
chrono::Utc::now(),
|
||||
None, false, None,
|
||||
"https://example.com/users/alice/followers".parse().unwrap(),
|
||||
);
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
assert!(json.contains(AS_PUBLIC));
|
||||
assert!(json.contains("Hello world"));
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use k_ap::NoteType;
|
||||
use k_ap::AS_PUBLIC;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
const STANDARD_NOTE_FIELDS: &[&str] = &[
|
||||
"type",
|
||||
"id",
|
||||
"attributedTo",
|
||||
"content",
|
||||
"published",
|
||||
"to",
|
||||
"cc",
|
||||
"inReplyTo",
|
||||
"sensitive",
|
||||
"summary",
|
||||
"tag",
|
||||
"url",
|
||||
"@context",
|
||||
"mediaType",
|
||||
];
|
||||
|
||||
pub fn extract_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter(|(k, _)| !STANDARD_NOTE_FIELDS.contains(&k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
if extensions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(extensions))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub url: Option<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>,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub tag: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct ThoughtNoteInput {
|
||||
pub id: Url,
|
||||
pub actor_url: Url,
|
||||
pub content: String,
|
||||
pub published: DateTime<Utc>,
|
||||
pub in_reply_to: Option<Url>,
|
||||
pub sensitive: bool,
|
||||
pub summary: Option<String>,
|
||||
pub followers_url: Url,
|
||||
}
|
||||
|
||||
impl ThoughtNote {
|
||||
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
|
||||
pub fn try_from_ap(mut value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
|
||||
let obj_type = value.get("type").and_then(|v| v.as_str());
|
||||
if !matches!(obj_type, Some("Note" | "Article" | "Page")) {
|
||||
return None;
|
||||
}
|
||||
let extensions = extract_extensions(&value);
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("type".to_string(), serde_json::json!("Note"));
|
||||
}
|
||||
serde_json::from_value(value)
|
||||
.ok()
|
||||
.map(|note| (note, extensions))
|
||||
}
|
||||
|
||||
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
||||
Self {
|
||||
kind: Default::default(),
|
||||
url: Some(p.id.clone()),
|
||||
id: p.id,
|
||||
attributed_to: p.actor_url,
|
||||
content: p.content,
|
||||
published: p.published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![p.followers_url.to_string()],
|
||||
in_reply_to: p.in_reply_to,
|
||||
sensitive: p.sensitive,
|
||||
summary: p.summary,
|
||||
tag: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,69 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_extensions_picks_up_non_standard_fields() {
|
||||
let obj = serde_json::json!({
|
||||
"type": "Note",
|
||||
"id": "https://example.com/notes/1",
|
||||
"content": "hello",
|
||||
"published": "2025-01-01T00:00:00Z",
|
||||
"movieTitle": "Dune",
|
||||
"rating": 5,
|
||||
"posterUrl": "https://example.com/poster.jpg"
|
||||
});
|
||||
let ext = extract_extensions(&obj).unwrap();
|
||||
assert_eq!(ext["movieTitle"], "Dune");
|
||||
assert_eq!(ext["rating"], 5);
|
||||
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
||||
assert!(ext.get("type").is_none());
|
||||
assert!(ext.get("content").is_none());
|
||||
assert!(ext.get("id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_extensions_returns_none_for_standard_only_note() {
|
||||
let obj = serde_json::json!({
|
||||
"type": "Note",
|
||||
"content": "hello",
|
||||
"published": "2025-01-01T00:00:00Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"tag": []
|
||||
});
|
||||
assert!(extract_extensions(&obj).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_extensions_returns_none_for_non_object() {
|
||||
let obj = serde_json::json!("not an object");
|
||||
assert!(extract_extensions(&obj).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_ap_returns_none_for_person() {
|
||||
let person = serde_json::json!({ "type": "Person", "id": "https://example.com/users/1" });
|
||||
assert!(ThoughtNote::try_from_ap(person).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_ap_returns_none_for_missing_type() {
|
||||
let obj = serde_json::json!({ "content": "hello" });
|
||||
assert!(ThoughtNote::try_from_ap(obj).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_serializes_with_public_audience() {
|
||||
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
|
||||
id: "https://example.com/thoughts/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/alice".parse().unwrap(),
|
||||
content: "Hello world".to_string(),
|
||||
published: chrono::Utc::now(),
|
||||
in_reply_to: None,
|
||||
sensitive: false,
|
||||
summary: None,
|
||||
followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
|
||||
});
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
assert!(json.contains(AS_PUBLIC));
|
||||
assert!(json.contains("Hello world"));
|
||||
assert!(json.contains("\"url\""));
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub use domain::ports::{
|
||||
AcceptNoteInput, ActorFederationUrls as ActorApUrls,
|
||||
FederationBroadcastPort as OutboundFederationPort,
|
||||
FederationContentRepository as ActivityPubRepository, OutboxEntry,
|
||||
};
|
||||
@@ -1,916 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_ap::ActivityPubService;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::remote_actor::RemoteActor as DomainRemoteActor,
|
||||
ports::{
|
||||
FederationFetchPort, FederationFollowPort, FederationFollowRequestPort,
|
||||
FederationLookupPort, FederationSchedulerPort, RemoteActorConnectionRepository,
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
||||
const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn content_to_html(text: &str) -> String {
|
||||
let escaped = text
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
||||
if paragraphs.is_empty() {
|
||||
format!("<p>{}</p>", escaped)
|
||||
} else {
|
||||
paragraphs
|
||||
.iter()
|
||||
.map(|p| format!("<p>{}</p>", p))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_note_json(
|
||||
thought: &domain::models::thought::Thought,
|
||||
local_actor_ap_id: &str,
|
||||
local_actor_followers_url: &str,
|
||||
base_url: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let ap_id = format!("{}/thoughts/{}", base_url, thought.id);
|
||||
|
||||
let (to, cc) = match thought.visibility {
|
||||
domain::models::thought::Visibility::Public => (
|
||||
vec![k_ap::AS_PUBLIC.to_string()],
|
||||
vec![local_actor_followers_url.to_string()],
|
||||
),
|
||||
domain::models::thought::Visibility::Unlisted => (
|
||||
vec![local_actor_followers_url.to_string()],
|
||||
vec![k_ap::AS_PUBLIC.to_string()],
|
||||
),
|
||||
domain::models::thought::Visibility::Followers => {
|
||||
(vec![local_actor_followers_url.to_string()], vec![])
|
||||
}
|
||||
domain::models::thought::Visibility::Direct => (vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut note = serde_json::json!({
|
||||
"type": "Note",
|
||||
"id": ap_id,
|
||||
"url": ap_id,
|
||||
"attributedTo": local_actor_ap_id,
|
||||
"content": content_to_html(thought.content.as_str()),
|
||||
"published": thought.created_at.to_rfc3339(),
|
||||
"to": to,
|
||||
"cc": cc,
|
||||
"sensitive": thought.sensitive,
|
||||
});
|
||||
if let Some(ref cw) = thought.content_warning {
|
||||
note["summary"] = serde_json::json!(cw);
|
||||
}
|
||||
if let Some(reply_url) = in_reply_to_url {
|
||||
note["inReplyTo"] = serde_json::json!(reply_url);
|
||||
}
|
||||
if let Some(updated_at) = thought.updated_at {
|
||||
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
|
||||
}
|
||||
let hashtags = domain::hashtag::extract(thought.content.as_str());
|
||||
if !hashtags.is_empty() {
|
||||
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);
|
||||
}
|
||||
if let Some(ref mood) = thought.mood {
|
||||
note["mood"] = serde_json::json!(mood);
|
||||
}
|
||||
if let Some(ref ext) = thought.note_extensions {
|
||||
if let Some(obj) = ext.as_object() {
|
||||
for (k, v) in obj {
|
||||
note.as_object_mut().unwrap().entry(k).or_insert(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
fn thought_to_ap_visibility(v: &domain::models::thought::Visibility) -> k_ap::ApVisibility {
|
||||
match v {
|
||||
domain::models::thought::Visibility::Public => k_ap::ApVisibility::Public,
|
||||
domain::models::thought::Visibility::Unlisted => k_ap::ApVisibility::Public,
|
||||
domain::models::thought::Visibility::Followers => k_ap::ApVisibility::FollowersOnly,
|
||||
domain::models::thought::Visibility::Direct => k_ap::ApVisibility::Private,
|
||||
}
|
||||
}
|
||||
|
||||
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||
DomainRemoteActor {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
outbox_url: a.outbox_url,
|
||||
last_fetched_at: a.fetched_at.unwrap_or_else(chrono::Utc::now),
|
||||
bio: a.bio,
|
||||
banner_url: a.banner_url,
|
||||
also_known_as: a.also_known_as,
|
||||
followers_url: a.followers_url,
|
||||
following_url: a.following_url,
|
||||
inbox_url: Some(a.inbox_url),
|
||||
shared_inbox_url: a.shared_inbox_url,
|
||||
attachment: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_actor_profiles_from_urls(
|
||||
urls: Vec<String>,
|
||||
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||
use futures::future;
|
||||
|
||||
async fn fetch_one(
|
||||
url: String,
|
||||
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||
let resp: serde_json::Value = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json")
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let ap_url = resp["id"].as_str()?.to_string();
|
||||
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
|
||||
let domain_str = url::Url::parse(&ap_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
let handle = format!("{}@{}", preferred_username, domain_str);
|
||||
let display_name = resp["name"].as_str().map(|s| s.to_string());
|
||||
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
|
||||
|
||||
Some(
|
||||
domain::models::actor_connection_summary::ActorConnectionSummary {
|
||||
url: ap_url,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
|
||||
let results = future::join_all(futs).await;
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
if r.is_none() {
|
||||
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
|
||||
let normalized = handle.trim_start_matches('@');
|
||||
let at = normalized
|
||||
.rfind('@')
|
||||
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
|
||||
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
||||
let wf_url = format!(
|
||||
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
|
||||
domain_str, user, domain_str
|
||||
);
|
||||
let wf: serde_json::Value = reqwest::Client::new()
|
||||
.get(&wf_url)
|
||||
.header("Accept", "application/jrd+json, application/json")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let self_href = wf["links"]
|
||||
.as_array()
|
||||
.and_then(|links| {
|
||||
links.iter().find(|l| {
|
||||
l["rel"].as_str() == Some("self")
|
||||
&& l["type"].as_str().is_some_and(|t| {
|
||||
t == "application/activity+json" || t.starts_with("application/ld+json")
|
||||
})
|
||||
})
|
||||
})
|
||||
.and_then(|l| l["href"].as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
|
||||
.to_owned();
|
||||
Ok(self_href)
|
||||
}
|
||||
|
||||
// ── ApFederationAdapter ───────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps `k_ap::ActivityPubService` together with the `RemoteActorConnectionRepository`
|
||||
/// (which k-ap doesn't own), and implements all domain federation port traits.
|
||||
#[derive(Clone)]
|
||||
pub struct ApFederationAdapter {
|
||||
pub(crate) inner: Arc<ActivityPubService>,
|
||||
pub(crate) connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
||||
}
|
||||
|
||||
impl ApFederationAdapter {
|
||||
pub fn new(
|
||||
inner: Arc<ActivityPubService>,
|
||||
connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
connections_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router<S>(&self) -> axum::Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
self.inner.router()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> &str {
|
||||
self.inner.base_url()
|
||||
}
|
||||
|
||||
fn actor_ap_id(&self, user_uuid: uuid::Uuid) -> String {
|
||||
format!("{}/users/{}", self.base_url(), user_uuid)
|
||||
}
|
||||
|
||||
fn actor_followers_url(&self, user_uuid: uuid::Uuid) -> String {
|
||||
format!("{}/followers", self.actor_ap_id(user_uuid))
|
||||
}
|
||||
}
|
||||
|
||||
// ── OutboundFederationPort ────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &domain::models::thought::Thought,
|
||||
_author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_uuid = author_user_id.as_uuid();
|
||||
let ap_id = self.actor_ap_id(user_uuid);
|
||||
let followers_url = self.actor_followers_url(user_uuid);
|
||||
let note = build_note_json(
|
||||
thought,
|
||||
&ap_id,
|
||||
&followers_url,
|
||||
self.base_url(),
|
||||
in_reply_to_url,
|
||||
);
|
||||
self.inner
|
||||
.broadcast_create_note(
|
||||
user_uuid,
|
||||
note,
|
||||
thought_to_ap_visibility(&thought.visibility),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_delete(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought_ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let ap_id =
|
||||
url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.inner
|
||||
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &domain::models::thought::Thought,
|
||||
_author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_uuid = author_user_id.as_uuid();
|
||||
let ap_id = self.actor_ap_id(user_uuid);
|
||||
let followers_url = self.actor_followers_url(user_uuid);
|
||||
let note = build_note_json(
|
||||
thought,
|
||||
&ap_id,
|
||||
&followers_url,
|
||||
self.base_url(),
|
||||
in_reply_to_url,
|
||||
);
|
||||
self.inner
|
||||
.broadcast_update_note(
|
||||
user_uuid,
|
||||
note,
|
||||
thought_to_ap_visibility(&thought.visibility),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let ap_id =
|
||||
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.inner
|
||||
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let ap_id =
|
||||
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.inner
|
||||
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let object =
|
||||
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let inbox =
|
||||
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.inner
|
||||
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_undo_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let object =
|
||||
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let inbox =
|
||||
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.inner
|
||||
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.broadcast_actor_update(user_id.as_uuid())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationSchedulerPort ───────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationSchedulerPort for ApFederationAdapter {
|
||||
async fn schedule_actor_posts_fetch(
|
||||
&self,
|
||||
actor_ap_url: &str,
|
||||
outbox_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let service = self.inner.clone();
|
||||
let actor = actor_ap_url.to_string();
|
||||
let outbox = outbox_url.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service.import_remote_outbox(&outbox, &actor).await {
|
||||
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_connections_fetch(
|
||||
&self,
|
||||
actor_ap_url: &str,
|
||||
collection_url: &str,
|
||||
connection_type: &str,
|
||||
_page: u32,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor = actor_ap_url.to_string();
|
||||
let collection = collection_url.to_string();
|
||||
let conn_type = connection_type.to_string();
|
||||
let connections_repo = self.connections_repo.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "connections fetch: failed to build client");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut all_urls: Vec<String> = Vec::new();
|
||||
let mut current_url: Option<String> = Some(collection.clone());
|
||||
const MAX_ACTORS: usize = 500;
|
||||
|
||||
while let Some(url) = current_url.take() {
|
||||
let val: serde_json::Value = match client
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, url = %url, "connections: parse error");
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, url = %url, "connections: HTTP error");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if val["type"].as_str() == Some("OrderedCollection") {
|
||||
current_url = val["first"].as_str().map(|s| s.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
let empty = vec![];
|
||||
let items = val["orderedItems"].as_array().unwrap_or(&empty);
|
||||
for item in items {
|
||||
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
|
||||
if !actor_url.is_empty() {
|
||||
all_urls.push(actor_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if all_urls.len() >= MAX_ACTORS {
|
||||
break;
|
||||
}
|
||||
current_url = val["next"].as_str().map(|s| s.to_string());
|
||||
if current_url.is_some() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if all_urls.is_empty() {
|
||||
tracing::debug!(
|
||||
actor = %actor,
|
||||
connection_type = %conn_type,
|
||||
"connections: empty collection"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const PAGE_SIZE: usize = 20;
|
||||
for (idx, chunk) in all_urls.chunks(PAGE_SIZE).enumerate() {
|
||||
let page_num = (idx + 1) as u32;
|
||||
let resolved = resolve_actor_profiles_from_urls(chunk.to_vec()).await;
|
||||
if let Err(e) = connections_repo
|
||||
.upsert_connections(&actor, &conn_type, page_num, &resolved)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "connections: upsert failed");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
actor = %actor,
|
||||
connection_type = %conn_type,
|
||||
count = all_urls.len(),
|
||||
"connections fetch complete"
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationLookupPort ──────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationLookupPort for ApFederationAdapter {
|
||||
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
||||
let actor = self
|
||||
.inner
|
||||
.lookup_actor_by_handle(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
Ok(DomainRemoteActor {
|
||||
url: actor.ap_url.to_string(),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
|
||||
last_fetched_at: chrono::Utc::now(),
|
||||
bio: actor.bio,
|
||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||
also_known_as: actor
|
||||
.also_known_as
|
||||
.into_iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect(),
|
||||
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
|
||||
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
|
||||
inbox_url: None,
|
||||
shared_inbox_url: None,
|
||||
attachment: actor
|
||||
.attachment
|
||||
.into_iter()
|
||||
.map(|f| (f.name, f.value))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError> {
|
||||
self.inner
|
||||
.actor_json(&user_id.as_uuid().to_string())
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
self.inner
|
||||
.followers_collection_json(user_id.as_uuid(), page)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
self.inner
|
||||
.following_collection_json(user_id.as_uuid(), page)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationFetchPort ───────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationFetchPort for ApFederationAdapter {
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<domain::models::remote_note::RemoteNote>, DomainError> {
|
||||
use chrono::DateTime;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let base: serde_json::Value = client
|
||||
.get(outbox_url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let first_url = base["first"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{}?page=1", outbox_url));
|
||||
|
||||
let mut current_url = first_url;
|
||||
let mut hops = 0u32;
|
||||
let target_page = page.max(1);
|
||||
let max_hops = 10u32;
|
||||
|
||||
let resp: serde_json::Value = loop {
|
||||
let page_resp: serde_json::Value = client
|
||||
.get(¤t_url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
hops += 1;
|
||||
if hops >= target_page || hops >= max_hops {
|
||||
break page_resp;
|
||||
}
|
||||
match page_resp["next"].as_str() {
|
||||
Some(next) => current_url = next.to_string(),
|
||||
None => break page_resp,
|
||||
}
|
||||
};
|
||||
|
||||
let empty = vec![];
|
||||
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||
|
||||
let notes = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let note = if item["type"].as_str() == Some("Create") {
|
||||
&item["object"]
|
||||
} else if item["type"].as_str() == Some("Note") {
|
||||
item
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let to = note["to"].as_array()?;
|
||||
let is_public = to
|
||||
.iter()
|
||||
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
|
||||
if !is_public {
|
||||
return None;
|
||||
}
|
||||
|
||||
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
||||
.ok()?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
let text = note["content"].as_str().unwrap_or("").to_string();
|
||||
let has_attachments = note["attachment"]
|
||||
.as_array()
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let content = if has_attachments {
|
||||
let notice =
|
||||
"<p class=\"media-notice\">📎 Media attachment — not supported</p>";
|
||||
if text.is_empty() {
|
||||
notice.to_string()
|
||||
} else {
|
||||
format!("{text}{notice}")
|
||||
}
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
Some(domain::models::remote_note::RemoteNote {
|
||||
ap_id: note["id"].as_str()?.to_string(),
|
||||
content,
|
||||
published,
|
||||
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
async fn fetch_actor_urls_from_collection(
|
||||
&self,
|
||||
collection_url: &str,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let client = reqwest::Client::new();
|
||||
let base: serde_json::Value = client
|
||||
.get(collection_url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let page = if base["orderedItems"].is_null() {
|
||||
if let Some(first_url) = base["first"].as_str() {
|
||||
client
|
||||
.get(first_url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
};
|
||||
|
||||
let empty = vec![];
|
||||
let items = page["orderedItems"].as_array().unwrap_or(&empty);
|
||||
Ok(items
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_actor_profiles(
|
||||
&self,
|
||||
urls: Vec<String>,
|
||||
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||
resolve_actor_profiles_from_urls(urls).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationFollowPort ──────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationFollowPort for ApFederationAdapter {
|
||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.follow(local_user_id.as_uuid(), handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn unfollow_remote(
|
||||
&self,
|
||||
local_user_id: &UserId,
|
||||
handle: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = webfinger_resolve_actor_url(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
self.inner
|
||||
.unfollow(local_user_id.as_uuid(), &actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_remote_following(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||
self.inner
|
||||
.get_following(user_id.as_uuid())
|
||||
.await
|
||||
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn broadcast_move(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
new_actor_url: url::Url,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.broadcast_move(user_id.as_uuid(), new_actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationFollowRequestPort ───────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationFollowRequestPort for ApFederationAdapter {
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||
self.inner
|
||||
.get_pending_followers(user_id.as_uuid())
|
||||
.await
|
||||
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn accept_follow_request(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.accept_follower(user_id.as_uuid(), actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn reject_follow_request(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.reject_follower(user_id.as_uuid(), actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_remote_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DomainRemoteActor>, DomainError> {
|
||||
self.inner
|
||||
.get_accepted_followers(user_id.as_uuid())
|
||||
.await
|
||||
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn remove_remote_follower(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.remove_follower(user_id.as_uuid(), actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn mark_follower_accepted(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.mark_follower_accepted(user_id.as_uuid(), actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn mark_follower_rejected(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner
|
||||
.mark_follower_rejected(user_id.as_uuid(), actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationBlockPort ──────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FederationBlockPort for ApFederationAdapter {
|
||||
async fn block_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
|
||||
let actor_url = webfinger_resolve_actor_url(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
self.inner
|
||||
.block_actor(local_user_id.as_uuid(), &actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn unblock_remote(
|
||||
&self,
|
||||
local_user_id: &UserId,
|
||||
handle: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = webfinger_resolve_actor_url(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
self.inner
|
||||
.unblock_actor(local_user_id.as_uuid(), &actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// FederationActionPort is a blanket supertrait; no explicit impl needed.
|
||||
49
crates/adapters/activitypub/src/urls.rs
Normal file
49
crates/adapters/activitypub/src/urls.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use url::Url;
|
||||
|
||||
pub struct ThoughtsUrls {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl ThoughtsUrls {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self { base_url: base_url.trim_end_matches('/').to_string() }
|
||||
}
|
||||
|
||||
pub fn user_url(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_inbox(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_outbox(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_followers(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thought_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
let id = uuid::Uuid::nil();
|
||||
assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/"));
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", self.base_url, id)).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, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/inbox", self.base_url, id)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_outbox(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/outbox", self.base_url, id)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_followers(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, id)).expect("valid URL")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,20 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
assert_eq!(
|
||||
urls.user_url("alice").as_str(),
|
||||
"https://example.com/users/alice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thought_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
let id = uuid::Uuid::nil();
|
||||
assert!(urls
|
||||
.thought_url(id)
|
||||
.as_str()
|
||||
.starts_with("https://example.com/thoughts/"));
|
||||
}
|
||||
@@ -13,7 +13,4 @@ tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
bcrypt = "0.15"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ApiKeyRepository, ApiKeyService},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ApiKeyServiceImpl {
|
||||
repo: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
impl ApiKeyServiceImpl {
|
||||
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
fn hash(raw: &str) -> String {
|
||||
hex::encode(Sha256::digest(raw.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyService for ApiKeyServiceImpl {
|
||||
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
|
||||
let hash = Self::hash(raw_key);
|
||||
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,61 +0,0 @@
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for FakeApiKeyRepo {
|
||||
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push(key.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k.key_hash == hash)
|
||||
.cloned())
|
||||
}
|
||||
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_known_key_returns_user_id() {
|
||||
let uid = UserId::new();
|
||||
let raw = "super-secret-key";
|
||||
let hash = ApiKeyServiceImpl::hash(raw);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: uid.clone(),
|
||||
key_hash: hash,
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key(raw).await.unwrap();
|
||||
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_unknown_key_returns_none() {
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key("unknown-key").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
mod api_key_service;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api_key_service::ApiKeyServiceImpl;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
@@ -25,10 +21,7 @@ pub struct JwtAuthService {
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(secret: String, ttl_seconds: i64) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
}
|
||||
Self { secret, ttl_seconds }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +51,8 @@ impl AuthService for JwtAuthService {
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized)?;
|
||||
let uuid =
|
||||
uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
|
||||
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||
.map_err(|_| DomainError::Unauthorized)?;
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
@@ -69,7 +62,10 @@ pub struct Argon2PasswordHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for Argon2PasswordHasher {
|
||||
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
|
||||
use argon2::{
|
||||
password_hash::SaltString,
|
||||
Argon2, PasswordHasher as _,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
let salt = SaltString::generate(OsRng);
|
||||
let hash = Argon2::default()
|
||||
@@ -80,12 +76,9 @@ impl PasswordHasher for Argon2PasswordHasher {
|
||||
}
|
||||
|
||||
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
if hash.0.starts_with("$2") {
|
||||
return bcrypt::verify(plain, &hash.0)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()));
|
||||
}
|
||||
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
|
||||
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let parsed = ArgonHash::new(&hash.0)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
@@ -93,4 +86,31 @@ impl PasswordHasher for Argon2PasswordHasher {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::AuthService;
|
||||
|
||||
#[test]
|
||||
fn generate_and_validate_token() {
|
||||
let svc = JwtAuthService::new("secret".into(), 3600);
|
||||
let id = UserId::new();
|
||||
let tok = svc.generate_token(&id).unwrap();
|
||||
let parsed = svc.validate_token(&tok.token).unwrap();
|
||||
assert_eq!(parsed.as_uuid(), id.as_uuid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_returns_unauthorized() {
|
||||
let svc = JwtAuthService::new("secret".into(), 3600);
|
||||
let err = svc.validate_token("not.a.token").unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_and_verify() {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash("mypassword").await.unwrap();
|
||||
assert!(hasher.verify("mypassword", &hash).await.unwrap());
|
||||
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
use super::*;
|
||||
use domain::ports::AuthService;
|
||||
|
||||
#[test]
|
||||
fn generate_and_validate_token() {
|
||||
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
|
||||
let id = UserId::new();
|
||||
let tok = svc.generate_token(&id).unwrap();
|
||||
let parsed = svc.validate_token(&tok.token).unwrap();
|
||||
assert_eq!(parsed.as_uuid(), id.as_uuid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_returns_unauthorized() {
|
||||
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
|
||||
let err = svc.validate_token("not.a.token").unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_and_verify() {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash("mypassword").await.unwrap();
|
||||
assert!(hasher.verify("mypassword", &hash).await.unwrap());
|
||||
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
|
||||
}
|
||||
@@ -68,62 +68,26 @@ pub enum EventPayload {
|
||||
UserRegistered {
|
||||
user_id: String,
|
||||
},
|
||||
ProfileUpdated {
|
||||
user_id: String,
|
||||
},
|
||||
RemoteFollowAccepted {
|
||||
local_user_id: String,
|
||||
remote_actor_url: String,
|
||||
},
|
||||
RemoteFollowRejected {
|
||||
local_user_id: String,
|
||||
remote_actor_url: String,
|
||||
},
|
||||
ActorMoved {
|
||||
user_id: String,
|
||||
new_actor_url: String,
|
||||
},
|
||||
MentionReceived {
|
||||
thought_id: String,
|
||||
mentioned_user_id: String,
|
||||
author_user_id: String,
|
||||
},
|
||||
FederationDeliveryRequested {
|
||||
inbox: String,
|
||||
activity: serde_json::Value,
|
||||
signing_actor_id: String,
|
||||
},
|
||||
FederationBackfillRequested {
|
||||
owner_user_id: String,
|
||||
follower_inbox_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
/// Returns the NATS subject for this event.
|
||||
pub fn subject(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||
Self::LikeAdded { .. } => "likes.added",
|
||||
Self::LikeRemoved { .. } => "likes.removed",
|
||||
Self::BoostAdded { .. } => "boosts.added",
|
||||
Self::BoostRemoved { .. } => "boosts.removed",
|
||||
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||
Self::LikeAdded { .. } => "likes.added",
|
||||
Self::LikeRemoved { .. } => "likes.removed",
|
||||
Self::BoostAdded { .. } => "boosts.added",
|
||||
Self::BoostRemoved { .. } => "boosts.removed",
|
||||
Self::FollowRequested { .. } => "follows.requested",
|
||||
Self::FollowAccepted { .. } => "follows.accepted",
|
||||
Self::FollowRejected { .. } => "follows.rejected",
|
||||
Self::Unfollowed { .. } => "follows.removed",
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
Self::ProfileUpdated { .. } => "users.profile_updated",
|
||||
Self::RemoteFollowAccepted { .. } => "federation.follow.accepted",
|
||||
Self::RemoteFollowRejected { .. } => "federation.follow.rejected",
|
||||
Self::ActorMoved { .. } => "federation.actor.moved",
|
||||
Self::MentionReceived { .. } => "mentions.received",
|
||||
Self::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
||||
Self::FederationBackfillRequested { .. } => "federation.backfill.requested",
|
||||
Self::FollowAccepted { .. } => "follows.accepted",
|
||||
Self::FollowRejected { .. } => "follows.rejected",
|
||||
Self::Unfollowed { .. } => "follows.removed",
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,139 +97,50 @@ impl EventPayload {
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => Self::ThoughtCreated {
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
|
||||
},
|
||||
DomainEvent::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => Self::ThoughtDeleted {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => Self::ThoughtUpdated {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeAdded {
|
||||
like_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::LikeAdded {
|
||||
like_id: like_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded {
|
||||
like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::LikeRemoved {
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::BoostAdded {
|
||||
boost_id: boost_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded {
|
||||
boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::BoostRemoved {
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRequested {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowRequested {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowAccepted {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRejected {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowRejected {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::Unfollowed {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::Unfollowed {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserBlocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => Self::UserBlocked {
|
||||
blocker_id: blocker_id.to_string(),
|
||||
blocked_id: blocked_id.to_string(),
|
||||
DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked {
|
||||
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserUnblocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => Self::UserUnblocked {
|
||||
blocker_id: blocker_id.to_string(),
|
||||
blocked_id: blocked_id.to_string(),
|
||||
DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked {
|
||||
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::RemoteFollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
} => Self::RemoteFollowAccepted {
|
||||
local_user_id: local_user_id.to_string(),
|
||||
remote_actor_url: remote_actor_url.clone(),
|
||||
},
|
||||
DomainEvent::RemoteFollowRejected {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
} => Self::RemoteFollowRejected {
|
||||
local_user_id: local_user_id.to_string(),
|
||||
remote_actor_url: remote_actor_url.clone(),
|
||||
},
|
||||
DomainEvent::ActorMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => Self::ActorMoved {
|
||||
user_id: user_id.to_string(),
|
||||
new_actor_url: new_actor_url.clone(),
|
||||
},
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => Self::MentionReceived {
|
||||
thought_id: thought_id.to_string(),
|
||||
mentioned_user_id: mentioned_user_id.to_string(),
|
||||
author_user_id: author_user_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,153 +157,105 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
|
||||
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||
Ok(match p {
|
||||
EventPayload::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => DomainEvent::ThoughtCreated {
|
||||
EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
in_reply_to_id: in_reply_to_id
|
||||
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
|
||||
.transpose()?,
|
||||
},
|
||||
EventPayload::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => DomainEvent::ThoughtDeleted {
|
||||
EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => DomainEvent::ThoughtUpdated {
|
||||
EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::LikeAdded {
|
||||
like_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::LikeAdded {
|
||||
EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded {
|
||||
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::LikeRemoved {
|
||||
EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostAdded {
|
||||
boost_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::BoostAdded {
|
||||
EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::BoostRemoved {
|
||||
EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::FollowRequested {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowRequested {
|
||||
EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowAccepted {
|
||||
EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowRejected {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowRejected {
|
||||
EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::Unfollowed {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::Unfollowed {
|
||||
EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::UserBlocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => DomainEvent::UserBlocked {
|
||||
EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
EventPayload::UserUnblocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => DomainEvent::UserUnblocked {
|
||||
EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::RemoteFollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
} => DomainEvent::RemoteFollowAccepted {
|
||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||
remote_actor_url,
|
||||
},
|
||||
EventPayload::RemoteFollowRejected {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
} => DomainEvent::RemoteFollowRejected {
|
||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||
remote_actor_url,
|
||||
},
|
||||
EventPayload::ActorMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => DomainEvent::ActorMoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
new_actor_url,
|
||||
},
|
||||
EventPayload::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => DomainEvent::MentionReceived {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
mentioned_user_id: UserId::from_uuid(parse_uuid(
|
||||
&mentioned_user_id,
|
||||
"mentioned_user_id",
|
||||
)?),
|
||||
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
|
||||
},
|
||||
EventPayload::FederationDeliveryRequested { .. }
|
||||
| EventPayload::FederationBackfillRequested { .. } => {
|
||||
return Err(DomainError::Internal(
|
||||
"federation infrastructure event — not a domain event".into(),
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thought_created_roundtrip() {
|
||||
let p = EventPayload::ThoughtCreated {
|
||||
thought_id: "abc".into(),
|
||||
user_id: "def".into(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_subjects_are_unique() {
|
||||
let samples: &[EventPayload] = &[
|
||||
EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None },
|
||||
EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() },
|
||||
];
|
||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||
subjects.sort();
|
||||
subjects.dedup();
|
||||
assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thought_created_roundtrip() {
|
||||
let p = EventPayload::ThoughtCreated {
|
||||
thought_id: "abc".into(),
|
||||
user_id: "def".into(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_subjects_are_unique() {
|
||||
let samples: &[EventPayload] = &[
|
||||
EventPayload::ThoughtCreated {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
in_reply_to_id: None,
|
||||
},
|
||||
EventPayload::ThoughtDeleted {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
},
|
||||
EventPayload::ThoughtUpdated {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
},
|
||||
EventPayload::LikeAdded {
|
||||
like_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::LikeRemoved {
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::BoostAdded {
|
||||
boost_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::BoostRemoved {
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::FollowRequested {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::FollowAccepted {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::FollowRejected {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::Unfollowed {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserBlocked {
|
||||
blocker_id: "a".into(),
|
||||
blocked_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserUnblocked {
|
||||
blocker_id: "a".into(),
|
||||
blocked_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserRegistered {
|
||||
user_id: "a".into(),
|
||||
},
|
||||
];
|
||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||
subjects.sort();
|
||||
subjects.dedup();
|
||||
assert_eq!(
|
||||
subjects.len(),
|
||||
samples.len(),
|
||||
"each event must have a unique subject"
|
||||
);
|
||||
}
|
||||
4
crates/adapters/event-publisher/Cargo.toml
Normal file
4
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[package]
|
||||
name = "event-publisher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
0
crates/adapters/event-publisher/src/lib.rs
Normal file
0
crates/adapters/event-publisher/src/lib.rs
Normal file
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "event-transport"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
@@ -1,113 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
ports::{EventConsumer, EventPublisher},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
/// Abstraction over any pub/sub transport backend.
|
||||
/// Implement this for NATS, Kafka, Redis Streams, etc.
|
||||
/// The adapter calls `publish_bytes(subject, bytes)` — subjects come from `EventPayload::subject()`.
|
||||
#[async_trait]
|
||||
pub trait Transport: Send + Sync {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Routes domain events to a transport backend.
|
||||
///
|
||||
/// Converts: `DomainEvent` → `EventPayload` → JSON bytes → `transport.publish_bytes(subject, bytes)`
|
||||
///
|
||||
/// To swap transports (e.g. NATS → Kafka), replace the `T` at the composition root.
|
||||
pub struct EventPublisherAdapter<T: Transport> {
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: Transport> EventPublisherAdapter<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let subject = payload.subject();
|
||||
let bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
tracing::debug!(subject, "publishing event");
|
||||
self.transport.publish_bytes(subject, &bytes).await
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw inbound message from a transport backend.
|
||||
/// `ack` and `nack` are transport-level acknowledgements (e.g. Kafka offset commit).
|
||||
/// For at-most-once transports (basic NATS), both are no-ops.
|
||||
pub struct RawMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub delivery_count: u64,
|
||||
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Abstraction over any subscribe/consume backend.
|
||||
pub trait MessageSource: Send + Sync {
|
||||
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
|
||||
}
|
||||
|
||||
/// Deserializes raw transport messages into domain `EventEnvelope`s.
|
||||
/// Invalid or unknown messages are skipped with a warning — stream continues.
|
||||
pub struct EventConsumerAdapter<S: MessageSource> {
|
||||
source: S,
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumerAdapter<S> {
|
||||
pub fn new(source: S) -> Self {
|
||||
Self { source }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
use futures::StreamExt;
|
||||
let stream = self.source.messages();
|
||||
Box::pin(stream.filter_map(|result| async move {
|
||||
match result {
|
||||
Err(e) => {
|
||||
tracing::warn!("transport error: {e}");
|
||||
None
|
||||
}
|
||||
Ok(msg) => {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize event payload — acking to prevent orphan: {e}");
|
||||
(msg.ack)();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("unknown or malformed event type — acking to prevent orphan: {e}");
|
||||
(msg.ack)();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Ok(EventEnvelope {
|
||||
event,
|
||||
delivery_count: msg.delivery_count,
|
||||
ack: msg.ack,
|
||||
nack: msg.nack,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,124 +0,0 @@
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::value_objects::{ThoughtId, UserId};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
struct SpyTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
impl SpyTransport {
|
||||
fn new() -> (Self, CallLog) {
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
(
|
||||
Self {
|
||||
calls: calls.clone(),
|
||||
},
|
||||
calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Transport for SpyTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((subject.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_routes_to_correct_subject() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher
|
||||
.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "thoughts.created");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serialized_payload_is_valid_json() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher
|
||||
.publish(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = calls.lock().unwrap()[0].1.clone();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
|
||||
assert_eq!(json["type"], "UserBlocked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_adapter_deserializes_and_yields_event() {
|
||||
use domain::value_objects::ThoughtId;
|
||||
use futures::StreamExt;
|
||||
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
struct OneMessageSource {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MessageSource for OneMessageSource {
|
||||
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let msg = RawMessage {
|
||||
subject: "thoughts.created".to_string(),
|
||||
payload: self.bytes.clone(),
|
||||
delivery_count: 1,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = EventConsumerAdapter::new(OneMessageSource { bytes });
|
||||
let mut stream = adapter.consume();
|
||||
let envelope = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_adapter_skips_invalid_payloads() {
|
||||
use futures::StreamExt;
|
||||
|
||||
struct BadMessageSource;
|
||||
#[async_trait::async_trait]
|
||||
impl MessageSource for BadMessageSource {
|
||||
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let msg = RawMessage {
|
||||
subject: "bad".to_string(),
|
||||
payload: b"not valid json".to_vec(),
|
||||
delivery_count: 1,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = EventConsumerAdapter::new(BadMessageSource);
|
||||
let mut stream = adapter.consume();
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
@@ -4,14 +4,13 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -1,243 +1,114 @@
|
||||
use async_nats::jetstream::{self, stream::Config as StreamConfig, AckKind};
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use event_transport::{MessageSource, RawMessage, Transport};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
ports::{EventConsumer, EventPublisher},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
use futures::stream::BoxStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
const STREAM_NAME: &str = "THOUGHTS_EVENTS";
|
||||
const STREAM_SUBJECT: &str = "thoughts-events.>";
|
||||
const CONSUMER_NAME: &str = "worker";
|
||||
const MAX_MESSAGES: i64 = 100_000;
|
||||
// ── NatsEventPublisher ────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum NATS delivery attempts before a message is considered exhausted.
|
||||
pub const CONSUMER_MAX_DELIVER: i64 = 5;
|
||||
/// How long NATS waits for an ack before redelivering.
|
||||
const CONSUMER_ACK_WAIT_SECS: u64 = 30;
|
||||
/// Timeout for spawned ack/nack async tasks.
|
||||
const ACK_TASK_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
fn stream_config() -> StreamConfig {
|
||||
StreamConfig {
|
||||
name: STREAM_NAME.to_string(),
|
||||
subjects: vec![STREAM_SUBJECT.to_string()],
|
||||
max_messages: MAX_MESSAGES,
|
||||
..Default::default()
|
||||
}
|
||||
pub struct NatsEventPublisher {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
/// Ensure the JetStream stream exists with the current config.
|
||||
/// If an incompatible stream exists (e.g. wrong retention policy), deletes and recreates it.
|
||||
pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> {
|
||||
let js = jetstream::new(client.clone());
|
||||
|
||||
// Happy path: stream exists and config is compatible.
|
||||
if js.update_stream(stream_config()).await.is_ok() {
|
||||
tracing::info!(subject = STREAM_SUBJECT, "JetStream stream updated");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update failed — retention policy mismatch or other incompatibility.
|
||||
// Delete the old stream and recreate with current config.
|
||||
tracing::warn!(
|
||||
"JetStream stream update failed (incompatible config), deleting '{STREAM_NAME}' and recreating"
|
||||
);
|
||||
let _ = js.delete_stream(STREAM_NAME).await;
|
||||
|
||||
js.create_stream(stream_config())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| DomainError::Internal(format!("JetStream stream create failed: {e}")))
|
||||
}
|
||||
|
||||
// ── NatsTransport — JetStream publish ──────────────────────────────────────
|
||||
|
||||
pub struct NatsTransport {
|
||||
jetstream: jetstream::Context,
|
||||
}
|
||||
|
||||
impl NatsTransport {
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self {
|
||||
jetstream: jetstream::new(client),
|
||||
}
|
||||
}
|
||||
impl NatsEventPublisher {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for NatsTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||
// Prefix all subjects so they land inside the stream's subject filter.
|
||||
let full_subject = format!("thoughts-events.{subject}");
|
||||
self.jetstream
|
||||
.publish(full_subject, bytes.to_vec().into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||
.await // wait for server ack — confirms message is durably stored
|
||||
impl EventPublisher for NatsEventPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let subject = payload.subject();
|
||||
let bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
self.client
|
||||
.publish(subject, bytes.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NatsMessageSource — JetStream durable push consumer ────────────────────
|
||||
// ── NatsEventConsumer ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct NatsMessageSource {
|
||||
jetstream: jetstream::Context,
|
||||
pub struct NatsEventConsumer {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsMessageSource {
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self {
|
||||
jetstream: jetstream::new(client),
|
||||
}
|
||||
}
|
||||
impl NatsEventConsumer {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
impl MessageSource for NatsMessageSource {
|
||||
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
use futures::stream;
|
||||
use tokio::sync::{mpsc, Mutex as TokioMutex};
|
||||
|
||||
let js = self.jetstream.clone();
|
||||
let (tx, rx) = mpsc::channel::<Result<RawMessage, DomainError>>(128);
|
||||
|
||||
// Spawn the consumer loop in the background.
|
||||
// Pull consumer: worker explicitly fetches from NATS rather than NATS pushing.
|
||||
tokio::spawn(async move {
|
||||
let stream = match js.get_stream(STREAM_NAME).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete any existing push consumer with this name — can't reuse as pull.
|
||||
// No-op if it doesn't exist or is already a pull consumer.
|
||||
if let Ok(info) = stream.consumer_info(CONSUMER_NAME).await {
|
||||
if info.config.deliver_subject.is_some() {
|
||||
tracing::info!(
|
||||
"deleting old push consumer '{CONSUMER_NAME}', replacing with pull"
|
||||
);
|
||||
let _ = stream.delete_consumer(CONSUMER_NAME).await;
|
||||
}
|
||||
}
|
||||
|
||||
let consumer = match stream
|
||||
.get_or_create_consumer(
|
||||
CONSUMER_NAME,
|
||||
jetstream::consumer::pull::Config {
|
||||
durable_name: Some(CONSUMER_NAME.to_string()),
|
||||
deliver_policy: jetstream::consumer::DeliverPolicy::New,
|
||||
ack_policy: jetstream::consumer::AckPolicy::Explicit,
|
||||
ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS),
|
||||
max_deliver: CONSUMER_MAX_DELIVER,
|
||||
// No filter_subject — consume everything from the stream.
|
||||
// filter_subject matching the stream's own wildcard can be
|
||||
// inconsistent across NATS server versions.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
impl EventConsumer for NatsEventConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async_stream::try_stream! {
|
||||
let mut sub = client
|
||||
.subscribe(">")
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
tracing::info!("NATS pull consumer ready");
|
||||
|
||||
loop {
|
||||
// consumer.messages() uses long-poll (no no_wait flag) — NATS holds the
|
||||
// request open and delivers messages as they arrive.
|
||||
// fetch() in async-nats 0.48 defaults to no_wait:true which returns
|
||||
// immediately when the queue is empty, so we avoid it here.
|
||||
let mut messages = match consumer.messages().await {
|
||||
Ok(m) => m,
|
||||
use futures::StreamExt;
|
||||
while let Some(msg) = sub.next().await {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("NATS messages() failed: {e}");
|
||||
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||
return;
|
||||
tracing::warn!("failed to deserialize event payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(result) = messages.next().await {
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("NATS message error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let subject = msg.subject.to_string();
|
||||
let payload = msg.payload.to_vec();
|
||||
let delivery_count = msg
|
||||
.info()
|
||||
.map(|info| info.delivered.max(0) as u64)
|
||||
.unwrap_or(1);
|
||||
let msg = Arc::new(msg);
|
||||
let msg_nack = Arc::clone(&msg);
|
||||
|
||||
let raw = RawMessage {
|
||||
subject,
|
||||
payload,
|
||||
delivery_count,
|
||||
ack: Box::new(move || {
|
||||
let m = Arc::clone(&msg);
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
|
||||
m.ack(),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"),
|
||||
Err(_) => tracing::warn!(
|
||||
"NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s"
|
||||
),
|
||||
}
|
||||
});
|
||||
}),
|
||||
nack: Box::new(move || {
|
||||
let m = Arc::clone(&msg_nack);
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
|
||||
m.ack_with(AckKind::Nak(None)),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"),
|
||||
Err(_) => tracing::warn!(
|
||||
"NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s"
|
||||
),
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
if tx.send(Ok(raw)).await.is_err() {
|
||||
return; // receiver dropped — worker shutting down
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to convert payload to domain event: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// messages() stream ended (e.g. fetch timeout) — loop and restart
|
||||
};
|
||||
// Basic NATS: no ack/nack (at-most-once delivery)
|
||||
yield EventEnvelope {
|
||||
event,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge the channel receiver into a BoxStream.
|
||||
let rx = Arc::new(TokioMutex::new(rx));
|
||||
Box::pin(stream::unfold(rx, |rx| async move {
|
||||
let item = rx.lock().await.recv().await?;
|
||||
Some((item, rx))
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::value_objects::{LikeId, ThoughtId, UserId};
|
||||
|
||||
#[test]
|
||||
fn payload_from_domain_event_has_correct_subject() {
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_roundtrip_via_payload() {
|
||||
let uid = UserId::new();
|
||||
let tid = ThoughtId::new();
|
||||
let event = DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: uid.clone(),
|
||||
thought_id: tid.clone(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back {
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
value_objects::{LikeId, ThoughtId, UserId},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
#[test]
|
||||
fn payload_from_domain_event_has_correct_subject() {
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_roundtrip_via_payload() {
|
||||
let uid = UserId::new();
|
||||
let tid = ThoughtId::new();
|
||||
let event = DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: uid.clone(),
|
||||
thought_id: tid.clone(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
if let DomainEvent::LikeAdded {
|
||||
user_id,
|
||||
thought_id,
|
||||
..
|
||||
} = back
|
||||
{
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,15 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
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 }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sqlx = { workspace = true, features = ["migrate"] }
|
||||
sqlx = { workspace = true, features = ["migrate"] }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../postgres/migrations
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,10 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated},
|
||||
thought::{Thought, Visibility},
|
||||
thought::Thought,
|
||||
user::User,
|
||||
},
|
||||
ports::SearchPort,
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use postgres::user::USER_SELECT;
|
||||
use sqlx::PgPool;
|
||||
use domain::models::thought::Visibility;
|
||||
|
||||
pub struct PgSearchRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgSearchRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
pub struct PgSearchRepository { pool: PgPool }
|
||||
impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
@@ -29,94 +22,131 @@ struct FeedRow {
|
||||
t_user_id: uuid::Uuid,
|
||||
content: String,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
in_reply_to_url: Option<String>,
|
||||
t_ap_id: Option<String>,
|
||||
visibility: String,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
t_local: bool,
|
||||
thought_created_at: DateTime<Utc>,
|
||||
thought_updated_at: Option<DateTime<Utc>>,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
mood: Option<String>,
|
||||
#[sqlx(flatten)]
|
||||
author: postgres::user::UserRow,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
author_id: uuid::Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
author_local: bool,
|
||||
u_ap_id: Option<String>,
|
||||
inbox_url: Option<String>,
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
author_created_at: DateTime<Utc>,
|
||||
author_updated_at: DateTime<Utc>,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
}
|
||||
|
||||
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
let viewer_checks = match viewer {
|
||||
Some(uid) => format!(
|
||||
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\
|
||||
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||
),
|
||||
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||
};
|
||||
format!(
|
||||
"\n SELECT\n\
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||
t.in_reply_to_id,\n\
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\
|
||||
u.id, u.username, u.email, u.password_hash,\n\
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
|
||||
u.local,\n\
|
||||
u.created_at, u.updated_at,\n\
|
||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
|
||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
|
||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
|
||||
{viewer_checks}\n\
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id"
|
||||
)
|
||||
}
|
||||
const FEED_SELECT: &str = "
|
||||
SELECT
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||
t.created_at AS thought_created_at, t.updated_at,
|
||||
u.id AS author_id, u.username, u.email, u.password_hash,
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
|
||||
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
|
||||
u.public_key, u.private_key,
|
||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
||||
|
||||
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
let thought = Thought {
|
||||
id: ThoughtId::from_uuid(r.thought_id),
|
||||
user_id: UserId::from_uuid(r.t_user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||
in_reply_to_url: r.in_reply_to_url,
|
||||
ap_id: r.t_ap_id,
|
||||
visibility: Visibility::from_str(&r.visibility),
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: r.t_local,
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.thought_updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
mood: r.mood,
|
||||
updated_at: r.updated_at,
|
||||
};
|
||||
let author = User::from(r.author);
|
||||
Ok(FeedEntry {
|
||||
thought,
|
||||
author,
|
||||
stats: domain::models::feed::EngagementStats {
|
||||
like_count: r.like_count,
|
||||
boost_count: r.boost_count,
|
||||
reply_count: r.reply_count,
|
||||
},
|
||||
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||
liked: r.liked_by_viewer,
|
||||
boosted: r.boosted_by_viewer,
|
||||
}),
|
||||
})
|
||||
let author = User {
|
||||
id: UserId::from_uuid(r.author_id),
|
||||
username: Username::from_trusted(r.username),
|
||||
email: Email::from_trusted(r.email),
|
||||
password_hash: PasswordHash(r.password_hash),
|
||||
display_name: r.display_name, bio: r.bio,
|
||||
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css,
|
||||
local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
||||
};
|
||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false }
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UserRow {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
local: bool,
|
||||
ap_id: Option<String>,
|
||||
inbox_url: Option<String>,
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<UserRow> for User {
|
||||
fn from(r: UserRow) -> Self {
|
||||
User {
|
||||
id: UserId::from_uuid(r.id),
|
||||
username: Username::from_trusted(r.username),
|
||||
email: Email::from_trusted(r.email),
|
||||
password_hash: PasswordHash(r.password_hash),
|
||||
display_name: r.display_name, bio: r.bio,
|
||||
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css,
|
||||
local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.created_at, updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const USER_SELECT: &str =
|
||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||
custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users";
|
||||
|
||||
#[async_trait]
|
||||
impl SearchPort for PgSearchRepository {
|
||||
async fn search_thoughts(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
_viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let select = feed_select(viewer);
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
WHERE t.content % $1 AND t.visibility='public'",
|
||||
WHERE t.content % $1 AND t.visibility='public'"
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -124,7 +154,7 @@ impl SearchPort for PgSearchRepository {
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sql = format!(
|
||||
"{select}
|
||||
"{FEED_SELECT}
|
||||
WHERE t.content % $1 AND t.visibility='public'
|
||||
ORDER BY similarity(t.content, $1) DESC
|
||||
LIMIT $2 OFFSET $3"
|
||||
@@ -138,10 +168,7 @@ impl SearchPort for PgSearchRepository {
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
items: rows.into_iter().map(row_to_entry).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
@@ -155,7 +182,7 @@ impl SearchPort for PgSearchRepository {
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users u
|
||||
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)",
|
||||
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)"
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -168,7 +195,7 @@ impl SearchPort for PgSearchRepository {
|
||||
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
||||
LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
|
||||
let rows = sqlx::query_as::<_, UserRow>(&sql)
|
||||
.bind(query)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
@@ -186,4 +213,61 @@ impl SearchPort for PgSearchRepository {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
ports::{SearchPort, ThoughtRepository, UserRepository},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(), u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
seed_thought(&pool, "bob", "goodbye universe").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
|
||||
use postgres::user::PgUserRepository;
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
urepo.save(&alice).await.unwrap();
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
assert!(!result.items.is_empty());
|
||||
assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("zzzzzzzzz", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{NewThought, Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{SearchPort, ThoughtRepository, UserWriter},
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
|
||||
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(NewThought {
|
||||
id: ThoughtId::new(),
|
||||
user_id: u.id.clone(),
|
||||
content: Content::new_local(content).unwrap(),
|
||||
in_reply_to_id: None,
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
seed_thought(&pool, "bob", "goodbye universe").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo
|
||||
.search_thoughts(
|
||||
"hello world",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
|
||||
use postgres::user::PgUserRepository;
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let alice = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice_search").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&alice).await.unwrap();
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo
|
||||
.search_users(
|
||||
"alice",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.items.is_empty());
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|u| u.username.as_str() == "alice_search"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo
|
||||
.search_thoughts(
|
||||
"zzzzzzzzz",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
|
||||
use domain::models::social::Like;
|
||||
use domain::ports::LikeRepository;
|
||||
use domain::value_objects::LikeId;
|
||||
use postgres::like::PgLikeRepository;
|
||||
|
||||
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
|
||||
|
||||
// alice likes her own thought
|
||||
let like_repo = PgLikeRepository::new(pool.clone());
|
||||
like_repo
|
||||
.save(&Like {
|
||||
id: LikeId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
|
||||
// with viewer — should see liked = true
|
||||
let authed = repo
|
||||
.search_thoughts(
|
||||
"hello",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
Some(&alice.id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(authed.items.len(), 1);
|
||||
let ctx = authed.items[0]
|
||||
.viewer
|
||||
.as_ref()
|
||||
.expect("viewer context present");
|
||||
assert!(ctx.liked, "alice should see the thought as liked");
|
||||
assert!(!ctx.boosted);
|
||||
|
||||
// without viewer — viewer should be None
|
||||
let anon = repo
|
||||
.search_thoughts(
|
||||
"hello",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anon.items.len(), 1);
|
||||
assert!(
|
||||
anon.items[0].viewer.is_none(),
|
||||
"anonymous request has no viewer context"
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,9 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE remote_actor_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_url TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL,
|
||||
page INT NOT NULL,
|
||||
connected_actor_url TEXT NOT NULL,
|
||||
connected_handle TEXT NOT NULL,
|
||||
connected_display_name TEXT,
|
||||
connected_avatar_url TEXT,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(actor_url, connection_type, page, connected_actor_url)
|
||||
);
|
||||
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Remote ActivityPub posts can exceed 128 characters.
|
||||
-- The 128-char limit is enforced at the application layer for local posts only.
|
||||
ALTER TABLE thoughts ALTER COLUMN content TYPE TEXT;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE failed_events (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
event_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
failed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
retry_at TIMESTAMPTZ NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT failed_events_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX failed_events_due_idx
|
||||
ON failed_events (retry_at)
|
||||
WHERE retry_count < 3;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Change in_reply_to_id FK from RESTRICT (default) to SET NULL.
|
||||
-- Previously, deleting a thought that had replies raised a FK violation.
|
||||
-- With SET NULL, deleting a thought orphans its replies (they survive but
|
||||
-- lose their parent reference), which is the correct semantic for a
|
||||
-- threaded social app.
|
||||
ALTER TABLE thoughts
|
||||
DROP CONSTRAINT IF EXISTS thoughts_in_reply_to_id_fkey;
|
||||
|
||||
ALTER TABLE thoughts
|
||||
ADD CONSTRAINT thoughts_in_reply_to_id_fkey
|
||||
FOREIGN KEY (in_reply_to_id) REFERENCES thoughts(id) ON DELETE SET NULL;
|
||||
@@ -1,10 +0,0 @@
|
||||
CREATE TABLE outbox_events (
|
||||
seq BIGSERIAL PRIMARY KEY,
|
||||
aggregate_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
delivered BOOLEAN NOT NULL DEFAULT false,
|
||||
delivered_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS federation_processed_activities (
|
||||
activity_id TEXT PRIMARY KEY,
|
||||
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_processed_activities_at
|
||||
ON federation_processed_activities(processed_at);
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE remote_actors
|
||||
ADD COLUMN IF NOT EXISTS bio TEXT,
|
||||
ADD COLUMN IF NOT EXISTS banner_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS followers_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS following_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS also_known_as TEXT[];
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Indexes for feed engagement counts and sorting.
|
||||
-- likes and boosts are joined/counted per thought on every feed query.
|
||||
-- thoughts(in_reply_to_id) is scanned for reply_count.
|
||||
CREATE INDEX IF NOT EXISTS idx_likes_thought_id ON likes(thought_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_boosts_thought_id ON boosts(thought_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_in_reply_to_id ON thoughts(in_reply_to_id) WHERE in_reply_to_id IS NOT NULL;
|
||||
|
||||
-- Viewer-context lookups: "did I like/boost this?"
|
||||
CREATE INDEX IF NOT EXISTS idx_likes_user_thought ON likes(user_id, thought_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_boosts_user_thought ON boosts(user_id, thought_id);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ALTER COLUMN username TYPE VARCHAR(255);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE federation_following
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE remote_actors ADD COLUMN IF NOT EXISTS attachment JSONB DEFAULT '[]'::jsonb;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;
|
||||
@@ -1,10 +0,0 @@
|
||||
INSERT INTO users (id, username, email, password_hash, display_name, bio)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
'instance',
|
||||
'noreply@instance.invalid',
|
||||
'!service-actor-no-login',
|
||||
NULL,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
267
crates/adapters/postgres/src/activitypub.rs
Normal file
267
crates/adapters/postgres/src/activitypub.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use url::Url;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::thought::{Thought, Visibility},
|
||||
ports::{ActivityPubRepository, OutboxEntry},
|
||||
value_objects::{Content, ThoughtId, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct PgActivityPubRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgActivityPubRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for PgActivityPubRepository {
|
||||
async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
username: String,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
in_reply_to_url: None,
|
||||
ap_id: None,
|
||||
visibility: Visibility::Public,
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
username: String,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
let rows = if let Some(before) = before {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||
ORDER BY t.created_at DESC LIMIT $3",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(before)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
in_reply_to_url: None,
|
||||
ap_id: None,
|
||||
visibility: Visibility::Public,
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result<Option<UserId>, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||
.bind(actor_ap_url.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(UserId::from_uuid))
|
||||
}
|
||||
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
|
||||
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let handle = actor_ap_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(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(new_id)
|
||||
.bind(&handle)
|
||||
.bind(format!("{}@remote", new_id))
|
||||
.bind(actor_ap_url.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
// Re-fetch to get whichever id won the race
|
||||
self.find_remote_actor_id(actor_ap_url)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into()))
|
||||
}
|
||||
|
||||
async fn accept_note(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
author_id: &UserId,
|
||||
content: &str,
|
||||
published: DateTime<Utc>,
|
||||
sensitive: bool,
|
||||
content_warning: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
let capped: String = content.chars().take(500).collect();
|
||||
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(uuid::Uuid::new_v4())
|
||||
.bind(author_id.as_uuid())
|
||||
.bind(&capped)
|
||||
.bind(ap_id.as_str())
|
||||
.bind(sensitive)
|
||||
.bind(content_warning)
|
||||
.bind(published)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(500).collect();
|
||||
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id.as_str())
|
||||
.bind(&capped)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> 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())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::ActivityPubRepository;
|
||||
|
||||
#[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();
|
||||
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();
|
||||
repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(&ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const MAX_REMOTE_CONTENT_CHARS: usize = 5000;
|
||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use activitypub::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::thought::{Thought, Visibility},
|
||||
value_objects::{Content, ThoughtId, UserId, Username},
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct OutboxRow {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
username: String,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl OutboxRow {
|
||||
fn into_entry(self) -> OutboxEntry {
|
||||
OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(self.id),
|
||||
user_id: UserId::from_uuid(self.user_id),
|
||||
content: Content::new_remote(self.content),
|
||||
in_reply_to_id: self.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
visibility: Visibility::Public,
|
||||
content_warning: self.content_warning,
|
||||
sensitive: self.sensitive,
|
||||
local: true,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
note_extensions: None,
|
||||
mood: None,
|
||||
},
|
||||
author_username: Username::from_trusted(self.username),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PgActivityPubRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgActivityPubRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for PgActivityPubRepository {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
sqlx::query_as::<_, OutboxRow>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|rows| rows.into_iter().map(OutboxRow::into_entry).collect())
|
||||
}
|
||||
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
let rows = if let Some(before) = before {
|
||||
sqlx::query_as::<_, OutboxRow>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||
ORDER BY t.created_at DESC LIMIT $3",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(before)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, OutboxRow>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.into_domain()?;
|
||||
|
||||
Ok(rows.into_iter().map(OutboxRow::into_entry).collect())
|
||||
}
|
||||
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
actor_ap_url: &str,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||
.bind(actor_ap_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|o| o.map(UserId::from_uuid))
|
||||
}
|
||||
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let parsed = url::Url::parse(actor_ap_url).ok();
|
||||
let domain_str = parsed
|
||||
.as_ref()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
let last_seg = parsed
|
||||
.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() || domain_str.is_empty() {
|
||||
format!("r_{}", &new_id.to_string()[..13])
|
||||
} else {
|
||||
let candidate = format!("{}@{}", last_seg, domain_str);
|
||||
if candidate.len() <= 255 {
|
||||
candidate
|
||||
} else {
|
||||
format!("r_{}", &new_id.to_string()[..13])
|
||||
}
|
||||
};
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(new_id)
|
||||
.bind(&handle)
|
||||
.bind(format!("{}@remote", new_id))
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let fallback = format!("r_{}", &new_id.to_string()[..13]);
|
||||
let new_id2 = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(new_id2)
|
||||
.bind(&fallback)
|
||||
.bind(format!("{}@remote", new_id2))
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
}
|
||||
|
||||
self.find_remote_actor_id(actor_ap_url)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DomainError::Internal(
|
||||
"intern_remote_actor: insert succeeded but row not found".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET display_name=$1, avatar_url=$2, updated_at=NOW()
|
||||
WHERE id=$3 AND local=false",
|
||||
)
|
||||
.bind(display_name)
|
||||
.bind(avatar_url)
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
|
||||
let AcceptNoteInput {
|
||||
ap_id,
|
||||
author_id,
|
||||
content,
|
||||
published,
|
||||
sensitive,
|
||||
content_warning,
|
||||
visibility,
|
||||
in_reply_to,
|
||||
note_extensions,
|
||||
} = input;
|
||||
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) => {
|
||||
// Fast path: local thought URL contains the UUID directly.
|
||||
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())
|
||||
});
|
||||
// Slow path: remote parent — look up by ap_id so remote-to-remote
|
||||
// replies are threaded correctly in the feed.
|
||||
let resolved = if local_uuid.is_some() {
|
||||
local_uuid
|
||||
} else {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind(url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()?
|
||||
};
|
||||
(resolved, Some(url.to_string()))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions)
|
||||
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4())
|
||||
.bind(author_id.as_uuid())
|
||||
.bind(&capped)
|
||||
.bind(ap_id)
|
||||
.bind(sensitive)
|
||||
.bind(content_warning)
|
||||
.bind(published)
|
||||
.bind(visibility)
|
||||
.bind(in_reply_to_id)
|
||||
.bind(&in_reply_to_url)
|
||||
.bind(note_extensions)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind(ap_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(ThoughtId::from_uuid(row.0))
|
||||
}
|
||||
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
new_content: &str,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||
sqlx::query(
|
||||
"UPDATE thoughts SET content=$2,note_extensions=$3,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(&capped)
|
||||
.bind(¬e_extensions)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
|
||||
)
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
|
||||
)
|
||||
.bind(thought_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
}
|
||||
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT ap_id, inbox_url FROM users \
|
||||
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
||||
}
|
||||
|
||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET display_name = ra.display_name, avatar_url = ra.avatar_url, updated_at = NOW()
|
||||
FROM remote_actors ra
|
||||
WHERE users.ap_id = ra.url AND users.ap_id = $1 AND users.local = false",
|
||||
)
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,70 +0,0 @@
|
||||
use super::*;
|
||||
use activitypub::{AcceptNoteInput, ActivityPubRepository};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let url = "https://mastodon.social/users/alice";
|
||||
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let actor_url = "https://remote.example/users/bob";
|
||||
let ap_id = "https://remote.example/notes/1";
|
||||
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||
repo.accept_note(AcceptNoteInput {
|
||||
ap_id,
|
||||
author_id: &author,
|
||||
content: "hello from remote",
|
||||
published: chrono::Utc::now(),
|
||||
sensitive: false,
|
||||
content_warning: None,
|
||||
visibility: "public",
|
||||
in_reply_to: None,
|
||||
note_extensions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool.clone());
|
||||
let actor_user_id = repo
|
||||
.intern_remote_actor("https://remote.example/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let thought_id = repo
|
||||
.accept_note(AcceptNoteInput {
|
||||
ap_id: "https://remote.example/notes/1",
|
||||
author_id: &actor_user_id,
|
||||
content: "Hello #rust world",
|
||||
published: chrono::Utc::now(),
|
||||
sensitive: false,
|
||||
content_warning: None,
|
||||
visibility: "public",
|
||||
in_reply_to: None,
|
||||
note_extensions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind("https://remote.example/notes/1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(thought_id.as_uuid(), row.0);
|
||||
}
|
||||
73
crates/adapters/postgres/src/api_key.rs
Normal file
73
crates/adapters/postgres/src/api_key.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}};
|
||||
|
||||
pub struct PgApiKeyRepository { pool: PgPool }
|
||||
impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for PgApiKeyRepository {
|
||||
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)")
|
||||
.bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1")
|
||||
.bind(hash).fetch_optional(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }))
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
|
||||
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
|
||||
.bind(id.as_uuid()).bind(user_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserRepository;
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
repo.save(&u).await.unwrap(); u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "abc123".into(), name: "test".into(), created_at: Utc::now() };
|
||||
repo.save(&key).await.unwrap();
|
||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||
assert_eq!(found.name, "test");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_key(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "def456".into(), name: "key2".into(), created_at: Utc::now() };
|
||||
repo.save(&key).await.unwrap();
|
||||
repo.delete(&key.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApiKeyRow {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
key_hash: String,
|
||||
name: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ApiKeyRow {
|
||||
fn into_domain(self) -> ApiKey {
|
||||
ApiKey {
|
||||
id: ApiKeyId::from_uuid(self.id),
|
||||
user_id: UserId::from_uuid(self.user_id),
|
||||
key_hash: self.key_hash,
|
||||
name: self.name,
|
||||
created_at: self.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PgApiKeyRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgApiKeyRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for PgApiKeyRepository {
|
||||
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
|
||||
)
|
||||
.bind(k.id.as_uuid())
|
||||
.bind(k.user_id.as_uuid())
|
||||
.bind(&k.key_hash)
|
||||
.bind(&k.name)
|
||||
.bind(k.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
sqlx::query_as::<_, ApiKeyRow>(
|
||||
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|o| o.map(ApiKeyRow::into_domain))
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
sqlx::query_as::<_, ApiKeyRow>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
|
||||
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
|
||||
.into_domain()
|
||||
.map(|rows| rows.into_iter().map(ApiKeyRow::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
|
||||
.bind(id.as_uuid())
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,49 +0,0 @@
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "abc123".into(),
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||
assert_eq!(found.name, "test");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_key(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "def456".into(),
|
||||
name: "key2".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
repo.delete(&key.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||
}
|
||||
81
crates/adapters/postgres/src/block.rs
Normal file
81
crates/adapters/postgres/src/block.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId};
|
||||
|
||||
pub struct PgBlockRepository { pool: PgPool }
|
||||
impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
#[async_trait]
|
||||
impl BlockRepository for PgBlockRepository {
|
||||
async fn save(&self, b: &Block) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
|
||||
)
|
||||
.bind(b.blocker_id.as_uuid())
|
||||
.bind(b.blocked_id.as_uuid())
|
||||
.bind(b.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2"
|
||||
)
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserRepository;
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into()));
|
||||
repo.save(&u).await.unwrap(); u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn block_exists(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||
repo.save(&block).await.unwrap();
|
||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unblock(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||
repo.save(&block).await.unwrap();
|
||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgBlockRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgBlockRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockRepository for PgBlockRepository {
|
||||
async fn save(&self, b: &Block) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
|
||||
)
|
||||
.bind(b.blocker_id.as_uuid())
|
||||
.bind(b.blocked_id.as_uuid())
|
||||
.bind(b.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,33 +0,0 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn block_exists(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unblock(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
}
|
||||
80
crates/adapters/postgres/src/boost.rs
Normal file
80
crates/adapters/postgres/src/boost.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::social::Boost, ports::BoostRepository, value_objects::{BoostId, ThoughtId, UserId}};
|
||||
|
||||
pub struct PgBoostRepository { pool: PgPool }
|
||||
impl PgBoostRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
#[async_trait]
|
||||
impl BoostRepository for PgBoostRepository {
|
||||
async fn save(&self, b: &Boost) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
|
||||
)
|
||||
.bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.fetch_optional(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
|
||||
}
|
||||
|
||||
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
|
||||
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::ports::{ThoughtRepository, UserRepository};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
repo.save(&boost).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unboost(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
repo.save(&boost).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user