Compare commits
135 Commits
f7ac6f6476
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f69cfb011 | |||
| 9aea5c1bd9 | |||
| 4d6df1ea60 | |||
| 5a65fda0bc | |||
| 6dbd4dafdc | |||
| 90d13c883b | |||
| 0c8fa01ab9 | |||
| 78daca0377 | |||
| 3357484bbf | |||
| 442a61bbdb | |||
| be27fe04e2 | |||
| 6040cf1e53 | |||
| 0b74344efe | |||
| 6d0b1a3121 | |||
| 020a79704f | |||
| 805bd9534f | |||
| 14a869cc8d | |||
| 6abd2e7aad | |||
| 9798a1d829 | |||
| 84edf58de6 | |||
| f9de21dcfa | |||
| 79f1e63bb8 | |||
| fc806f82a4 | |||
| bcd86fbfe7 | |||
| 5b4b747dd7 | |||
| bd370776fe | |||
| ecb61f9b8f | |||
| 37d03a06dd | |||
| 55e5bcc2bb | |||
| ac26eaca6b | |||
| 86d0497509 | |||
| 989004dd74 | |||
| 64cc11c2a1 | |||
| 01ef118b0a | |||
| 4ab6da67c7 | |||
| dc75ac5f6c | |||
| b14b8592a2 | |||
| 4db7194838 | |||
| c94b42cba8 | |||
| 1ad6f8ae8f | |||
| d76ff9dafb | |||
| 522ee9c1b1 | |||
| 00996327fb | |||
| 7ed639c9ea | |||
| 3ad609a793 | |||
| 9849bb4991 | |||
| 2199e5c66d | |||
| 6e7bf05942 | |||
| 037217960e | |||
| 44b3a6de60 | |||
| 1fd46f3f2a | |||
| 9c5d5518bb | |||
| 95ea633e78 | |||
| a97507cc15 | |||
| 858faddda9 | |||
| ea3a32ccaf | |||
| 8fad8eefa0 | |||
| 5a05968ae9 | |||
| 8229285a2f | |||
| 145b07d636 | |||
| 7991aef47b | |||
| ed6a4f9f72 | |||
| f815d71c32 | |||
| 0688ffe0ae | |||
| 95728302b7 | |||
| 4d00d856c1 | |||
| a279988d39 | |||
| 2f56839938 | |||
| 2ffdd5e269 | |||
| a73e7deeff | |||
| c5812100d5 | |||
| 43e36c743b | |||
| e406464f9f | |||
| 0e2b72b77a | |||
| d4da172398 | |||
| 4e750420bf | |||
| e6330125be | |||
| 14b7928026 | |||
| a6a555e6a7 | |||
| 4d4171a9c5 | |||
| 75e6fe61ca | |||
| 4f1b9a5cfb | |||
| d68c628335 | |||
| af5c4481b6 | |||
| 5e3db44043 | |||
| 915163aac4 | |||
| a06d09c101 | |||
| 0dce4fbe64 | |||
| 9c93baaa39 | |||
| a253efacec | |||
| 04f39e35c2 | |||
| 2060317867 | |||
| e338254099 | |||
| 84c66dd461 | |||
| 2445cad1c9 | |||
| fc290dc18f | |||
| 43e5175db5 | |||
| 50a90efbce | |||
| ff75361eb1 | |||
| 5ca5ad9561 | |||
| f6893b19dc | |||
| 6f65742284 | |||
| 904dd5f1a0 | |||
| 0164b03e5c | |||
| 0797dde39c | |||
| 7d2d597264 | |||
| fe4960d30d | |||
| 5097c91261 | |||
| f4932af2ba | |||
| fccc4064cf | |||
| 01932cf337 | |||
| 1874954ad7 | |||
| f12cc7e2a7 | |||
| 6936b7ce62 | |||
| d56d34cc27 | |||
| 2f5c89c381 | |||
| 2c2decba72 | |||
| 2d1044e5c3 | |||
| d813e59b5c | |||
| 54910c6459 | |||
| be0924d463 | |||
| 2c34eb44e4 | |||
| 7dcdbb4551 | |||
| bb48819cad | |||
| 39f7d39232 | |||
| 4a84c595d5 | |||
| f89a466fd9 | |||
| c180b1c1f5 | |||
| a85cb2eee5 | |||
| 7e2c5adffd | |||
| 82778c82dd | |||
| b02f3c73e3 | |||
| a0aa3f381e | |||
| 6c685d19e8 | |||
| 9aee4ceb6d |
9
.cargo/config.toml
Normal file
9
.cargo/config.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[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
|
||||||
18
.claude/settings.json
Normal file
18
.claude/settings.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
.env.example
18
.env.example
@@ -9,7 +9,7 @@ BASE_URL=http://localhost:3000
|
|||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=8000
|
||||||
|
|
||||||
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
@@ -24,5 +24,21 @@ RUST_ENV=development # set to "production" to disable AP debug mode
|
|||||||
# but events will not be delivered to the worker)
|
# but events will not be delivered to the worker)
|
||||||
# NATS_URL=nats://localhost:4222
|
# 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
|
# Logging
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|||||||
@@ -21,32 +21,3 @@ jobs:
|
|||||||
--exclude postgres-federation \
|
--exclude postgres-federation \
|
||||||
--exclude postgres-search
|
--exclude postgres-search
|
||||||
|
|
||||||
# Integration tests — require a real PostgreSQL instance.
|
|
||||||
# These test that the SQL queries in the adapter crates are correct.
|
|
||||||
integration:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
env:
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_DB: thoughts_test
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
env:
|
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/thoughts_test
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: integration tests
|
|
||||||
run: |
|
|
||||||
cargo test \
|
|
||||||
-p postgres \
|
|
||||||
-p postgres-federation \
|
|
||||||
-p postgres-search
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
|
/.superpowers/
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
/docs/superpowers/
|
||||||
|
/media
|
||||||
|
|||||||
164
ARCHITECTURE.md
Normal file
164
ARCHITECTURE.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# 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
|
||||||
502
Cargo.lock
generated
502
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,12 @@ members = [
|
|||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/postgres-search",
|
"crates/adapters/postgres-search",
|
||||||
"crates/adapters/postgres-federation",
|
"crates/adapters/postgres-federation",
|
||||||
"crates/adapters/activitypub-base",
|
|
||||||
"crates/adapters/activitypub",
|
"crates/adapters/activitypub",
|
||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/nats",
|
"crates/adapters/nats",
|
||||||
"crates/adapters/event-payload",
|
"crates/adapters/event-payload",
|
||||||
"crates/adapters/event-transport",
|
"crates/adapters/event-transport",
|
||||||
|
"crates/adapters/storage",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -30,9 +30,10 @@ async-trait = "0.1"
|
|||||||
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
bytes = "1.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
async-nats = "0.48"
|
async-nats = "0.48"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
@@ -46,9 +47,9 @@ api-types = { path = "crates/api-types" }
|
|||||||
postgres = { path = "crates/adapters/postgres" }
|
postgres = { path = "crates/adapters/postgres" }
|
||||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||||
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
|
||||||
activitypub = { path = "crates/adapters/activitypub" }
|
activitypub = { path = "crates/adapters/activitypub" }
|
||||||
auth = { path = "crates/adapters/auth" }
|
auth = { path = "crates/adapters/auth" }
|
||||||
nats = { path = "crates/adapters/nats" }
|
nats = { path = "crates/adapters/nats" }
|
||||||
event-payload = { path = "crates/adapters/event-payload" }
|
event-payload = { path = "crates/adapters/event-payload" }
|
||||||
event-transport = { path = "crates/adapters/event-transport" }
|
event-transport = { path = "crates/adapters/event-transport" }
|
||||||
|
storage = { path = "crates/adapters/storage" }
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ FROM rust:slim-bookworm AS builder
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Cache dependency compilation separately from source
|
# Cache dependency compilation separately from source
|
||||||
|
COPY .cargo/ .cargo/
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||||
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
|
|
||||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/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-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/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||||
@@ -35,7 +36,7 @@ RUN cargo fetch
|
|||||||
# Now copy real source and build
|
# Now copy real source and build
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
|
|
||||||
RUN cargo build --release -p bootstrap -p worker
|
RUN cargo build --release -p bootstrap -p worker --features storage/s3
|
||||||
|
|
||||||
# ----- runtime -----
|
# ----- runtime -----
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -51,7 +52,7 @@ WORKDIR /app
|
|||||||
COPY --from=builder /build/target/release/thoughts ./thoughts
|
COPY --from=builder /build/target/release/thoughts ./thoughts
|
||||||
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
|
|||||||
48
Makefile
Normal file
48
Makefile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.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
|
||||||
195
README.md
195
README.md
@@ -11,12 +11,51 @@ A self-hosted microblogging server with full ActivityPub federation. Write short
|
|||||||
- Content negotiation at `GET /users/{username}` — serves ActivityPub actor JSON or REST profile based on `Accept` header
|
- 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
|
- 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
|
- 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)
|
- JWT authentication (Bearer token) with API key support for third-party clients
|
||||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||||
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||||
- Top friends — pin up to 5 users as highlighted contacts
|
- **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment
|
||||||
- API keys for third-party client access
|
- **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
|
- 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
|
## Architecture
|
||||||
|
|
||||||
@@ -31,37 +70,105 @@ bootstrap — binary: thoughts (API server)
|
|||||||
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
||||||
adapters/
|
adapters/
|
||||||
auth — JWT issuance and validation, Argon2 password hashing
|
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 — PostgreSQL repositories for all domain entities
|
||||||
postgres-search — PostgreSQL trigram full-text search
|
postgres-search — PostgreSQL trigram full-text search
|
||||||
postgres-federation — PostgreSQL-backed federation repository
|
postgres-federation — PostgreSQL-backed federation repository
|
||||||
activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware
|
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)
|
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
|
||||||
nats — NATS transport implementing Transport + MessageSource ports
|
nats — NATS transport implementing Transport + MessageSource ports
|
||||||
event-payload — shared event serialization DTOs
|
event-payload — shared event serialization DTOs
|
||||||
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Rust stable (1.80+)
|
- Rust stable (1.80+)
|
||||||
- PostgreSQL 15+
|
- PostgreSQL 15+
|
||||||
- NATS (optional — federation and notifications still work without it, events queue in-process)
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill in your values:
|
Copy `.env.example` to `.env` and fill in your values.
|
||||||
|
|
||||||
```env
|
### Required
|
||||||
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
|
|
||||||
JWT_SECRET=change-me
|
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NATS_URL=nats://localhost:4222 # optional
|
|
||||||
```
|
|
||||||
|
|
||||||
See `.env.example` for all available options.
|
| 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
|
## 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
|
```bash
|
||||||
# API server (runs migrations automatically on startup)
|
# API server (runs migrations automatically on startup)
|
||||||
cargo run -p bootstrap
|
cargo run -p bootstrap
|
||||||
@@ -75,14 +182,20 @@ Both processes share the same PostgreSQL database. The worker is optional but re
|
|||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Unit tests — no database required
|
# Unit tests only — no database required
|
||||||
cargo test -p application
|
make test-unit
|
||||||
|
|
||||||
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
|
# Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
|
||||||
cargo test --workspace
|
make test-integration
|
||||||
|
|
||||||
|
# Everything (unit + integration)
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Full check suite: fmt + clippy + tests
|
||||||
|
make check
|
||||||
```
|
```
|
||||||
|
|
||||||
The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
|
`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
|
## API
|
||||||
|
|
||||||
@@ -95,18 +208,7 @@ Interactive API documentation is available at runtime:
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables:
|
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.
|
||||||
|
|
||||||
```env
|
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests
|
|
||||||
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd thoughts-frontend
|
|
||||||
bun install
|
|
||||||
bun run dev # http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -121,6 +223,9 @@ docker run -p 8000:8000 \
|
|||||||
-e JWT_SECRET=change-me \
|
-e JWT_SECRET=change-me \
|
||||||
-e BASE_URL=https://yourdomain.example.com \
|
-e BASE_URL=https://yourdomain.example.com \
|
||||||
-e NATS_URL=nats://nats:4222 \
|
-e NATS_URL=nats://nats:4222 \
|
||||||
|
-e STORAGE_BACKEND=local \
|
||||||
|
-e STORAGE_PATH=/data/media \
|
||||||
|
-v media_vol:/data/media \
|
||||||
thoughts
|
thoughts
|
||||||
|
|
||||||
# Event worker (same image, different entrypoint)
|
# Event worker (same image, different entrypoint)
|
||||||
@@ -139,7 +244,35 @@ docker build -t thoughts-frontend \
|
|||||||
docker run -p 3000:3000 thoughts-frontend
|
docker run -p 3000:3000 thoughts-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
See `compose.yml` for a full local development stack.
|
### 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,18 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=traefik"
|
- "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.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
|
||||||
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
|
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.thoughts-api.service=thoughts-api"
|
- "traefik.http.routers.thoughts-api.service=thoughts-api"
|
||||||
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
|
- "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:
|
worker:
|
||||||
container_name: thoughts-worker
|
container_name: thoughts-worker
|
||||||
@@ -77,6 +84,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
||||||
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
|
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
|
||||||
|
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ services:
|
|||||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
||||||
JWT_SECRET: change-me-in-production
|
JWT_SECRET: change-me-in-production
|
||||||
BASE_URL: http://localhost:8000
|
BASE_URL: http://localhost:8000
|
||||||
|
PORT: 8000
|
||||||
NATS_URL: nats://nats:4222
|
NATS_URL: nats://nats:4222
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
|
STORAGE_BACKEND: local
|
||||||
|
STORAGE_PATH: /data/media
|
||||||
|
volumes:
|
||||||
|
- media_data:/data/media
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -65,3 +70,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
media_data:
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "activitypub-base"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { workspace = true }
|
|
||||||
futures = { 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"
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
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())
|
|
||||||
&& let Ok(url) = Url::parse(obj_url)
|
|
||||||
&& 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
|
|
||||||
&& 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: 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 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();
|
|
||||||
|
|
||||||
// Extract object URL — handles plain string and Tombstone {"id":"...","type":"Tombstone"}
|
|
||||||
let object_url_str = match &self.object {
|
|
||||||
serde_json::Value::String(s) => s.clone(),
|
|
||||||
serde_json::Value::Object(o) => o
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
let Ok(object_url) = Url::parse(&object_url_str) else {
|
|
||||||
tracing::warn!(actor = %actor_url, "Delete activity has unparseable object, ignoring");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actor self-deletion: Mastodon sends Delete(actor_url) when an account is deleted.
|
|
||||||
if object_url == *self.actor.inner() {
|
|
||||||
data.object_handler
|
|
||||||
.on_actor_removed(&actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal note deletion.
|
|
||||||
data.object_handler
|
|
||||||
.on_delete(&object_url, &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
|
||||||
tracing::info!(object = %object_url, "received Delete(note)");
|
|
||||||
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>>,
|
|
||||||
#[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 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),
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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)))
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
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: json.summary.clone(),
|
|
||||||
avatar_url: json.icon.as_ref().map(|i| i.url.clone()),
|
|
||||||
banner_url: json.image.as_ref().map(|i| i.url.clone()),
|
|
||||||
also_known_as: json.also_known_as.into_iter().next(),
|
|
||||||
profile_url: json.url.clone(),
|
|
||||||
attachment: json
|
|
||||||
.attachment
|
|
||||||
.iter()
|
|
||||||
.map(|f| crate::user::ApProfileField {
|
|
||||||
name: f.name.clone(),
|
|
||||||
value: f.value.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 activitypub_federation::kinds::object::NoteType;
|
|
||||||
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};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
fn _assert_impl_federation_action_port()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _assert_impl_federation_action_port_connections()
|
|
||||||
where
|
|
||||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -14,3 +14,7 @@ chrono = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
|||||||
@@ -1,66 +1,52 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
const USERS_PATH_PREFIX: &str = "/users/";
|
||||||
|
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::note::ThoughtNote;
|
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||||
|
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||||
use crate::urls::ThoughtsUrls;
|
use crate::urls::ThoughtsUrls;
|
||||||
use activitypub_base::ApObjectHandler;
|
use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository};
|
||||||
use domain::ports::ActivityPubRepository;
|
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
use k_ap::{ApContentReader, ApObjectHandler};
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
repo: Arc<dyn ActivityPubRepository>,
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
urls: ThoughtsUrls,
|
urls: ThoughtsUrls,
|
||||||
|
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||||
|
tag_repo: Arc<dyn TagRepository>,
|
||||||
|
likes: Arc<dyn LikeRepository>,
|
||||||
|
boosts: Arc<dyn BoostRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThoughtsObjectHandler {
|
impl ThoughtsObjectHandler {
|
||||||
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
|
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 {
|
Self {
|
||||||
repo,
|
repo,
|
||||||
urls: ThoughtsUrls::new(base_url),
|
urls: ThoughtsUrls::new(base_url),
|
||||||
|
event_publisher,
|
||||||
|
tag_repo,
|
||||||
|
likes,
|
||||||
|
boosts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ApContentReader ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
impl ApContentReader 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(
|
async fn get_local_objects_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
@@ -78,41 +64,59 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.map(|e| {
|
.map(|e| {
|
||||||
let created_at = e.thought.created_at;
|
let created_at = e.thought.created_at;
|
||||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
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 actor_url = self.urls.user_url(&user_id.to_string());
|
||||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
let followers = self.urls.user_followers(&user_id.to_string());
|
||||||
let in_reply_to = e
|
let in_reply_to = e
|
||||||
.thought
|
.thought
|
||||||
.in_reply_to_id
|
.in_reply_to_id
|
||||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
let note = ThoughtNote::new_public(
|
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||||
note_url.clone(),
|
id: note_url.clone(),
|
||||||
actor_url,
|
actor_url,
|
||||||
e.thought.content.as_str().to_owned(),
|
content: e.thought.content.as_str().to_owned(),
|
||||||
created_at,
|
published: created_at,
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
e.thought.sensitive,
|
sensitive: e.thought.sensitive,
|
||||||
e.thought.content_warning,
|
summary: e.thought.content_warning,
|
||||||
followers,
|
followers_url: followers,
|
||||||
);
|
});
|
||||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||||
})
|
})
|
||||||
.collect()
|
.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(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
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
|
let author_id = self
|
||||||
.repo
|
.repo
|
||||||
.intern_remote_actor(actor_url)
|
.intern_remote_actor(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
let _ = self
|
||||||
|
.repo
|
||||||
|
.sync_remote_actor_to_user(actor_url.as_str())
|
||||||
|
.await;
|
||||||
|
|
||||||
// Derive visibility from AP addressing conventions.
|
|
||||||
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
let in_to = note.to.iter().any(|s| s == as_public);
|
let in_to = note.to.iter().any(|s| s == as_public);
|
||||||
let in_cc = note.cc.iter().any(|s| s == as_public);
|
let in_cc = note.cc.iter().any(|s| s == as_public);
|
||||||
@@ -129,51 +133,362 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
"direct"
|
"direct"
|
||||||
};
|
};
|
||||||
|
|
||||||
self.repo
|
let thought_id = self
|
||||||
.accept_note(
|
.repo
|
||||||
ap_id,
|
.accept_note(AcceptNoteInput {
|
||||||
&author_id,
|
ap_id: ap_id.as_str(),
|
||||||
¬e.content,
|
author_id: &author_id,
|
||||||
note.published,
|
content: ¬e.content,
|
||||||
note.sensitive,
|
published: note.published,
|
||||||
note.summary,
|
sensitive: note.sensitive,
|
||||||
|
content_warning: note.summary,
|
||||||
visibility,
|
visibility,
|
||||||
)
|
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||||
|
note_extensions,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_update(
|
async fn on_update(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
_actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
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
|
self.repo
|
||||||
.apply_note_update(ap_id, ¬e.content)
|
.apply_note_update(ap_id.as_str(), ¬e.content, note_extensions)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
self.repo
|
||||||
.retract_note(ap_id)
|
.retract_note(ap_id.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
self.repo
|
||||||
.retract_actor_notes(actor_url)
|
.retract_actor_notes(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> Result<u64> {
|
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
let thought_uuid = object_url
|
||||||
.count_local_notes()
|
.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
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,62 @@
|
|||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod note;
|
pub mod note;
|
||||||
|
pub mod port;
|
||||||
|
pub mod service;
|
||||||
pub mod urls;
|
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 handler::ThoughtsObjectHandler;
|
||||||
pub use note::ThoughtNote;
|
pub use note::ThoughtNote;
|
||||||
|
pub use port::{
|
||||||
|
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
|
||||||
|
};
|
||||||
|
pub use service::ApFederationAdapter;
|
||||||
pub use urls::ThoughtsUrls;
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
use activitypub_base::NoteType;
|
|
||||||
use activitypub_base::AS_PUBLIC;
|
|
||||||
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 url: Url, // Mastodon uses this as the clickable link
|
|
||||||
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 {
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
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(),
|
|
||||||
url: id.clone(),
|
|
||||||
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"));
|
|
||||||
assert!(json.contains("\"url\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
110
crates/adapters/activitypub/src/note/mod.rs
Normal file
110
crates/adapters/activitypub/src/note/mod.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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;
|
||||||
69
crates/adapters/activitypub/src/note/tests.rs
Normal file
69
crates/adapters/activitypub/src/note/tests.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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\""));
|
||||||
|
}
|
||||||
5
crates/adapters/activitypub/src/port.rs
Normal file
5
crates/adapters/activitypub/src/port.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub use domain::ports::{
|
||||||
|
AcceptNoteInput, ActorFederationUrls as ActorApUrls,
|
||||||
|
FederationBroadcastPort as OutboundFederationPort,
|
||||||
|
FederationContentRepository as ActivityPubRepository, OutboxEntry,
|
||||||
|
};
|
||||||
916
crates/adapters/activitypub/src/service.rs
Normal file
916
crates/adapters/activitypub/src/service.rs
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
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.
|
||||||
@@ -1,57 +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, 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/"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
crates/adapters/activitypub/src/urls/mod.rs
Normal file
36
crates/adapters/activitypub/src/urls/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
20
crates/adapters/activitypub/src/urls/tests.rs
Normal file
20
crates/adapters/activitypub/src/urls/tests.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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/"));
|
||||||
|
}
|
||||||
@@ -15,3 +15,5 @@ jsonwebtoken = "9"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|||||||
33
crates/adapters/auth/src/api_key_service/mod.rs
Normal file
33
crates/adapters/auth/src/api_key_service/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
61
crates/adapters/auth/src/api_key_service/tests.rs
Normal file
61
crates/adapters/auth/src/api_key_service/tests.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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,3 +1,5 @@
|
|||||||
|
mod api_key_service;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use domain::{
|
use domain::{
|
||||||
@@ -8,6 +10,8 @@ use domain::{
|
|||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use api_key_service::ApiKeyServiceImpl;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct Claims {
|
struct Claims {
|
||||||
sub: String,
|
sub: String,
|
||||||
@@ -89,31 +93,4 @@ impl PasswordHasher for Argon2PasswordHasher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
26
crates/adapters/auth/src/tests.rs
Normal file
26
crates/adapters/auth/src/tests.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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,15 +68,34 @@ pub enum EventPayload {
|
|||||||
UserRegistered {
|
UserRegistered {
|
||||||
user_id: String,
|
user_id: String,
|
||||||
},
|
},
|
||||||
FetchRemoteActorPosts {
|
ProfileUpdated {
|
||||||
actor_ap_url: String,
|
user_id: String,
|
||||||
outbox_url: String,
|
|
||||||
},
|
},
|
||||||
FetchActorConnections {
|
RemoteFollowAccepted {
|
||||||
actor_ap_url: String,
|
local_user_id: String,
|
||||||
collection_url: String,
|
remote_actor_url: String,
|
||||||
connection_type: String,
|
},
|
||||||
page: u32,
|
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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +117,13 @@ impl EventPayload {
|
|||||||
Self::UserBlocked { .. } => "users.blocked",
|
Self::UserBlocked { .. } => "users.blocked",
|
||||||
Self::UserUnblocked { .. } => "users.unblocked",
|
Self::UserUnblocked { .. } => "users.unblocked",
|
||||||
Self::UserRegistered { .. } => "users.registered",
|
Self::UserRegistered { .. } => "users.registered",
|
||||||
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
|
Self::ProfileUpdated { .. } => "users.profile_updated",
|
||||||
Self::FetchActorConnections { .. } => "federation.fetch_connections",
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,23 +233,38 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
},
|
},
|
||||||
DomainEvent::FetchRemoteActorPosts {
|
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
|
||||||
actor_ap_url,
|
user_id: user_id.to_string(),
|
||||||
outbox_url,
|
|
||||||
} => Self::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: actor_ap_url.clone(),
|
|
||||||
outbox_url: outbox_url.clone(),
|
|
||||||
},
|
},
|
||||||
DomainEvent::FetchActorConnections {
|
DomainEvent::RemoteFollowAccepted {
|
||||||
actor_ap_url,
|
local_user_id,
|
||||||
collection_url,
|
remote_actor_url,
|
||||||
connection_type,
|
} => Self::RemoteFollowAccepted {
|
||||||
page,
|
local_user_id: local_user_id.to_string(),
|
||||||
} => Self::FetchActorConnections {
|
remote_actor_url: remote_actor_url.clone(),
|
||||||
actor_ap_url: actor_ap_url.clone(),
|
},
|
||||||
collection_url: collection_url.clone(),
|
DomainEvent::RemoteFollowRejected {
|
||||||
connection_type: connection_type.clone(),
|
local_user_id,
|
||||||
page: *page,
|
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(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,123 +384,51 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
},
|
},
|
||||||
EventPayload::FetchRemoteActorPosts {
|
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
|
||||||
actor_ap_url,
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
outbox_url,
|
|
||||||
} => DomainEvent::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url,
|
|
||||||
outbox_url,
|
|
||||||
},
|
},
|
||||||
EventPayload::FetchActorConnections {
|
EventPayload::RemoteFollowAccepted {
|
||||||
actor_ap_url,
|
local_user_id,
|
||||||
collection_url,
|
remote_actor_url,
|
||||||
connection_type,
|
} => DomainEvent::RemoteFollowAccepted {
|
||||||
page,
|
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||||
} => DomainEvent::FetchActorConnections {
|
remote_actor_url,
|
||||||
actor_ap_url,
|
|
||||||
collection_url,
|
|
||||||
connection_type,
|
|
||||||
page,
|
|
||||||
},
|
},
|
||||||
|
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)]
|
#[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(),
|
|
||||||
},
|
|
||||||
EventPayload::UserUnblocked {
|
|
||||||
blocker_id: "a".into(),
|
|
||||||
blocked_id: "b".into(),
|
|
||||||
},
|
|
||||||
EventPayload::UserRegistered {
|
|
||||||
user_id: "a".into(),
|
|
||||||
},
|
|
||||||
EventPayload::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
|
||||||
},
|
|
||||||
EventPayload::FetchActorConnections {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
collection_url: "https://mastodon.social/users/alice/followers".into(),
|
|
||||||
connection_type: "followers".into(),
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
85
crates/adapters/event-payload/src/tests.rs
Normal file
85
crates/adapters/event-payload/src/tests.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
|||||||
pub struct RawMessage {
|
pub struct RawMessage {
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub payload: Vec<u8>,
|
pub payload: Vec<u8>,
|
||||||
|
pub delivery_count: u64,
|
||||||
pub ack: Box<dyn Fn() + Send + Sync>,
|
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||||
pub nack: Box<dyn Fn() + Send + Sync>,
|
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||||
}
|
}
|
||||||
@@ -83,19 +84,22 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
|||||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("failed to deserialize event payload: {e}");
|
tracing::warn!("failed to deserialize event payload — acking to prevent orphan: {e}");
|
||||||
|
(msg.ack)();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let event = match DomainEvent::try_from(payload) {
|
let event = match DomainEvent::try_from(payload) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("unknown event type: {e}");
|
tracing::warn!("unknown or malformed event type — acking to prevent orphan: {e}");
|
||||||
|
(msg.ack)();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Some(Ok(EventEnvelope {
|
Some(Ok(EventEnvelope {
|
||||||
event,
|
event,
|
||||||
|
delivery_count: msg.delivery_count,
|
||||||
ack: msg.ack,
|
ack: msg.ack,
|
||||||
nack: msg.nack,
|
nack: msg.nack,
|
||||||
}))
|
}))
|
||||||
@@ -106,125 +110,4 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::value_objects::{ThoughtId, UserId};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
struct SpyTransport {
|
|
||||||
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
|
||||||
}
|
|
||||||
impl SpyTransport {
|
|
||||||
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
|
|
||||||
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(),
|
|
||||||
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(),
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
124
crates/adapters/event-transport/src/tests.rs
Normal file
124
crates/adapters/event-transport/src/tests.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ const STREAM_SUBJECT: &str = "thoughts-events.>";
|
|||||||
const CONSUMER_NAME: &str = "worker";
|
const CONSUMER_NAME: &str = "worker";
|
||||||
const MAX_MESSAGES: i64 = 100_000;
|
const MAX_MESSAGES: i64 = 100_000;
|
||||||
|
|
||||||
|
/// 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 {
|
fn stream_config() -> StreamConfig {
|
||||||
StreamConfig {
|
StreamConfig {
|
||||||
name: STREAM_NAME.to_string(),
|
name: STREAM_NAME.to_string(),
|
||||||
@@ -121,6 +128,10 @@ impl MessageSource for NatsMessageSource {
|
|||||||
CONSUMER_NAME,
|
CONSUMER_NAME,
|
||||||
jetstream::consumer::pull::Config {
|
jetstream::consumer::pull::Config {
|
||||||
durable_name: Some(CONSUMER_NAME.to_string()),
|
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.
|
// No filter_subject — consume everything from the stream.
|
||||||
// filter_subject matching the stream's own wildcard can be
|
// filter_subject matching the stream's own wildcard can be
|
||||||
// inconsistent across NATS server versions.
|
// inconsistent across NATS server versions.
|
||||||
@@ -164,25 +175,48 @@ impl MessageSource for NatsMessageSource {
|
|||||||
|
|
||||||
let subject = msg.subject.to_string();
|
let subject = msg.subject.to_string();
|
||||||
let payload = msg.payload.to_vec();
|
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 = Arc::new(msg);
|
||||||
let msg_nack = Arc::clone(&msg);
|
let msg_nack = Arc::clone(&msg);
|
||||||
|
|
||||||
let raw = RawMessage {
|
let raw = RawMessage {
|
||||||
subject,
|
subject,
|
||||||
payload,
|
payload,
|
||||||
|
delivery_count,
|
||||||
ack: Box::new(move || {
|
ack: Box::new(move || {
|
||||||
let m = Arc::clone(&msg);
|
let m = Arc::clone(&msg);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = m.ack().await {
|
let result = tokio::time::timeout(
|
||||||
tracing::warn!("NATS ack failed: {e}");
|
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 || {
|
nack: Box::new(move || {
|
||||||
let m = Arc::clone(&msg_nack);
|
let m = Arc::clone(&msg_nack);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = m.ack_with(AckKind::Nak(None)).await {
|
let result = tokio::time::timeout(
|
||||||
tracing::warn!("NATS nak failed: {e}");
|
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"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -206,46 +240,4 @@ impl MessageSource for NatsMessageSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
40
crates/adapters/nats/src/tests.rs
Normal file
40
crates/adapters/nats/src/tests.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
activitypub-base = { workspace = true }
|
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
@@ -12,6 +12,7 @@ tracing = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
1
crates/adapters/postgres-federation/migrations
Symbolic link
1
crates/adapters/postgres-federation/migrations
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../postgres/migrations
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
postgres = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
fn visibility_from_str(s: &str) -> domain::models::thought::Visibility {
|
|
||||||
use domain::models::thought::Visibility;
|
|
||||||
match s {
|
|
||||||
"followers" => Visibility::Followers,
|
|
||||||
"unlisted" => Visibility::Unlisted,
|
|
||||||
"direct" => Visibility::Direct,
|
|
||||||
_ => Visibility::Public,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
feed::{FeedEntry, PageParams, Paginated},
|
||||||
thought::Thought,
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::SearchPort,
|
ports::SearchPort,
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
use postgres::user::USER_SELECT;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
pub struct PgSearchRepository {
|
pub struct PgSearchRepository {
|
||||||
@@ -38,132 +29,79 @@ struct FeedRow {
|
|||||||
t_user_id: uuid::Uuid,
|
t_user_id: uuid::Uuid,
|
||||||
content: String,
|
content: String,
|
||||||
in_reply_to_id: Option<uuid::Uuid>,
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
in_reply_to_url: Option<String>,
|
|
||||||
t_ap_id: Option<String>,
|
|
||||||
visibility: String,
|
visibility: String,
|
||||||
content_warning: Option<String>,
|
content_warning: Option<String>,
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
t_local: bool,
|
t_local: bool,
|
||||||
thought_created_at: DateTime<Utc>,
|
thought_created_at: DateTime<Utc>,
|
||||||
updated_at: Option<DateTime<Utc>>,
|
thought_updated_at: Option<DateTime<Utc>>,
|
||||||
author_id: uuid::Uuid,
|
note_extensions: Option<serde_json::Value>,
|
||||||
username: String,
|
mood: Option<String>,
|
||||||
email: String,
|
#[sqlx(flatten)]
|
||||||
password_hash: String,
|
author: postgres::user::UserRow,
|
||||||
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>,
|
|
||||||
author_created_at: DateTime<Utc>,
|
|
||||||
author_updated_at: DateTime<Utc>,
|
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
boost_count: i64,
|
boost_count: i64,
|
||||||
reply_count: i64,
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEED_SELECT: &str = "
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
SELECT
|
let viewer_checks = match viewer {
|
||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
Some(uid) => format!(
|
||||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||||
t.created_at AS thought_created_at, t.updated_at,
|
),
|
||||||
u.id AS author_id, u.username, u.email, u.password_hash,
|
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
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,
|
format!(
|
||||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
"\n SELECT\n\
|
||||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
t.in_reply_to_id,\n\
|
||||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
let thought = Thought {
|
let thought = Thought {
|
||||||
id: ThoughtId::from_uuid(r.thought_id),
|
id: ThoughtId::from_uuid(r.thought_id),
|
||||||
user_id: UserId::from_uuid(r.t_user_id),
|
user_id: UserId::from_uuid(r.t_user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: r.in_reply_to_url,
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||||
ap_id: r.t_ap_id,
|
|
||||||
visibility: visibility_from_str(&r.visibility),
|
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
created_at: r.thought_created_at,
|
created_at: r.thought_created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.thought_updated_at,
|
||||||
|
note_extensions: r.note_extensions,
|
||||||
|
mood: r.mood,
|
||||||
};
|
};
|
||||||
let author = User {
|
let author = User::from(r.author);
|
||||||
id: UserId::from_uuid(r.author_id),
|
Ok(FeedEntry {
|
||||||
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,
|
|
||||||
created_at: r.author_created_at,
|
|
||||||
updated_at: r.author_updated_at,
|
|
||||||
};
|
|
||||||
FeedEntry {
|
|
||||||
thought,
|
thought,
|
||||||
author,
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
boost_count: r.boost_count,
|
boost_count: r.boost_count,
|
||||||
reply_count: r.reply_count,
|
reply_count: r.reply_count,
|
||||||
liked_by_viewer: false,
|
},
|
||||||
boosted_by_viewer: false,
|
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||||
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[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>,
|
|
||||||
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,
|
|
||||||
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,created_at,updated_at FROM users";
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SearchPort for PgSearchRepository {
|
impl SearchPort for PgSearchRepository {
|
||||||
@@ -171,8 +109,11 @@ impl SearchPort for PgSearchRepository {
|
|||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
_viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
|
let select = feed_select(viewer);
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
WHERE t.content % $1 AND t.visibility='public'",
|
WHERE t.content % $1 AND t.visibility='public'",
|
||||||
@@ -183,7 +124,7 @@ impl SearchPort for PgSearchRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"{FEED_SELECT}
|
"{select}
|
||||||
WHERE t.content % $1 AND t.visibility='public'
|
WHERE t.content % $1 AND t.visibility='public'
|
||||||
ORDER BY similarity(t.content, $1) DESC
|
ORDER BY similarity(t.content, $1) DESC
|
||||||
LIMIT $2 OFFSET $3"
|
LIMIT $2 OFFSET $3"
|
||||||
@@ -197,7 +138,10 @@ impl SearchPort for PgSearchRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -224,7 +168,7 @@ impl SearchPort for PgSearchRepository {
|
|||||||
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
||||||
LIMIT $2 OFFSET $3"
|
LIMIT $2 OFFSET $3"
|
||||||
);
|
);
|
||||||
let rows = sqlx::query_as::<_, UserRow>(&sql)
|
let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
@@ -242,105 +186,4 @@ impl SearchPort for PgSearchRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
164
crates/adapters/postgres-search/src/tests.rs
Normal file
164
crates/adapters/postgres-search/src/tests.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
activitypub = { workspace = true }
|
||||||
|
event-payload = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;
|
||||||
15
crates/adapters/postgres/migrations/009_failed_events.sql
Normal file
15
crates/adapters/postgres/migrations/009_failed_events.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 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;
|
||||||
10
crates/adapters/postgres/migrations/011_outbox_events.sql
Normal file
10
crates/adapters/postgres/migrations/011_outbox_events.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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[];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ALTER COLUMN username TYPE VARCHAR(255);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE federation_following
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE remote_actors ADD COLUMN IF NOT EXISTS attachment JSONB DEFAULT '[]'::jsonb;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;
|
||||||
10
crates/adapters/postgres/migrations/022_instance_actor.sql
Normal file
10
crates/adapters/postgres/migrations/022_instance_actor.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
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();
|
|
||||||
// Use the last path segment as username (e.g. /users/alice → "alice").
|
|
||||||
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
|
||||||
// username column is VARCHAR(32).
|
|
||||||
let last_seg = actor_ap_url
|
|
||||||
.path_segments()
|
|
||||||
.and_then(|mut s| s.next_back())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let handle = if last_seg.is_empty() {
|
|
||||||
format!("remote_{}", &new_id.to_string()[..13])
|
|
||||||
} else if last_seg.len() <= 32 {
|
|
||||||
last_seg
|
|
||||||
} else {
|
|
||||||
format!("remote_{}", &new_id.to_string()[..13])
|
|
||||||
};
|
|
||||||
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 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
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn accept_note(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
author_id: &UserId,
|
|
||||||
content: &str,
|
|
||||||
published: DateTime<Utc>,
|
|
||||||
sensitive: bool,
|
|
||||||
content_warning: Option<String>,
|
|
||||||
visibility: &str,
|
|
||||||
) -> 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,$8,$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)
|
|
||||||
.bind(visibility)
|
|
||||||
.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,
|
|
||||||
"public",
|
|
||||||
)
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
363
crates/adapters/postgres/src/activitypub/mod.rs
Normal file
363
crates/adapters/postgres/src/activitypub/mod.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
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;
|
||||||
70
crates/adapters/postgres/src/activitypub/tests.rs
Normal file
70
crates/adapters/postgres/src/activitypub/tests.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
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 crate::user::PgUserRepository;
|
|
||||||
use chrono::Utc;
|
|
||||||
use domain::ports::UserRepository;
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
89
crates/adapters/postgres/src/api_key/mod.rs
Normal file
89
crates/adapters/postgres/src/api_key/mod.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
49
crates/adapters/postgres/src/api_key/tests.rs
Normal file
49
crates/adapters/postgres/src/api_key/tests.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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
|
|
||||||
.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 crate::user::PgUserRepository;
|
|
||||||
use chrono::Utc;
|
|
||||||
use domain::ports::UserRepository;
|
|
||||||
use domain::{models::user::User, value_objects::*};
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
55
crates/adapters/postgres/src/block/mod.rs
Normal file
55
crates/adapters/postgres/src/block/mod.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
33
crates/adapters/postgres/src/block/tests.rs
Normal file
33
crates/adapters/postgres/src/block/tests.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
models::social::Boost,
|
|
||||||
ports::BoostRepository,
|
|
||||||
value_objects::{BoostId, ThoughtId, UserId},
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
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 crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
|
||||||
use chrono::Utc;
|
|
||||||
use domain::ports::{ThoughtRepository, UserRepository};
|
|
||||||
use domain::{
|
|
||||||
models::{
|
|
||||||
thought::{Thought, Visibility},
|
|
||||||
user::User,
|
|
||||||
},
|
|
||||||
value_objects::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
74
crates/adapters/postgres/src/boost/mod.rs
Normal file
74
crates/adapters/postgres/src/boost/mod.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::db_error::IntoDbResult;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::social::Boost,
|
||||||
|
ports::BoostRepository,
|
||||||
|
value_objects::{BoostId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
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.into_domain().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
|
||||||
|
.into_domain()?;
|
||||||
|
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
|
||||||
|
.into_domain()
|
||||||
|
.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
|
||||||
|
.into_domain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
34
crates/adapters/postgres/src/boost/tests.rs
Normal file
34
crates/adapters/postgres/src/boost/tests.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::test_helpers::seed_user_and_thought;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed_user_and_thought(&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_user_and_thought(&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);
|
||||||
|
}
|
||||||
8
crates/adapters/postgres/src/constants.rs
Normal file
8
crates/adapters/postgres/src/constants.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub const STATUS_ACCEPTED: &str = "accepted";
|
||||||
|
pub const STATUS_PENDING: &str = "pending";
|
||||||
|
pub const STATUS_REJECTED: &str = "rejected";
|
||||||
|
|
||||||
|
pub const VIS_PUBLIC: &str = "public";
|
||||||
|
pub const VIS_UNLISTED: &str = "unlisted";
|
||||||
|
pub const VIS_FOLLOWERS: &str = "followers";
|
||||||
|
pub const VIS_DIRECT: &str = "direct";
|
||||||
20
crates/adapters/postgres/src/db_error.rs
Normal file
20
crates/adapters/postgres/src/db_error.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub(crate) trait IntoDbResult<T> {
|
||||||
|
fn into_domain(self) -> Result<T, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> IntoDbResult<T> for Result<T, sqlx::Error> {
|
||||||
|
fn into_domain(self) -> Result<T, DomainError> {
|
||||||
|
self.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(ref db) = e {
|
||||||
|
if db.code().as_deref() == Some("23505") {
|
||||||
|
return DomainError::Conflict(
|
||||||
|
db.constraint().unwrap_or("conflict").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DomainError::Internal(e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
83
crates/adapters/postgres/src/engagement.rs
Normal file
83
crates/adapters/postgres/src/engagement.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use crate::db_error::IntoDbResult;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::feed::{EngagementStats, ViewerContext},
|
||||||
|
ports::EngagementRepository,
|
||||||
|
value_objects::{ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct PgEngagementRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgEngagementRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EngagementRepository for PgEngagementRepository {
|
||||||
|
async fn get_for_thoughts(
|
||||||
|
&self,
|
||||||
|
thought_ids: &[ThoughtId],
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError> {
|
||||||
|
if thought_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
thought_id: uuid::Uuid,
|
||||||
|
like_count: i64,
|
||||||
|
boost_count: i64,
|
||||||
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids: Vec<uuid::Uuid> = thought_ids.iter().map(|t| t.as_uuid()).collect();
|
||||||
|
let viewer_uuid: Option<uuid::Uuid> = viewer_id.map(|v| v.as_uuid());
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT
|
||||||
|
t.id AS thought_id,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT b.user_id) AS boost_count,
|
||||||
|
COUNT(DISTINCT r.id) AS reply_count,
|
||||||
|
COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer,
|
||||||
|
COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer
|
||||||
|
FROM thoughts t
|
||||||
|
LEFT JOIN likes l ON l.thought_id = t.id
|
||||||
|
LEFT JOIN boosts b ON b.thought_id = t.id
|
||||||
|
LEFT JOIN thoughts r ON r.in_reply_to_id = t.id
|
||||||
|
WHERE t.id = ANY($1)
|
||||||
|
GROUP BY t.id",
|
||||||
|
)
|
||||||
|
.bind(&ids[..])
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let tid = ThoughtId::from_uuid(row.thought_id);
|
||||||
|
let stats = EngagementStats {
|
||||||
|
like_count: row.like_count,
|
||||||
|
boost_count: row.boost_count,
|
||||||
|
reply_count: row.reply_count,
|
||||||
|
};
|
||||||
|
let viewer = viewer_id.map(|_| ViewerContext {
|
||||||
|
liked: row.liked_by_viewer,
|
||||||
|
boosted: row.boosted_by_viewer,
|
||||||
|
});
|
||||||
|
result.insert(tid, (stats, viewer));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
crates/adapters/postgres/src/failed_event.rs
Normal file
105
crates/adapters/postgres/src/failed_event.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// How many times a failed event is retried by the DLQ processor.
|
||||||
|
pub const DLQ_MAX_RETRIES: i32 = 3;
|
||||||
|
/// Quarantine period for the first DLQ retry (seconds). Doubles each retry.
|
||||||
|
pub const DLQ_INITIAL_BACKOFF_SECS: i64 = 300; // 5 minutes
|
||||||
|
/// How often the DLQ processor polls for due retries (seconds).
|
||||||
|
pub const DLQ_POLL_INTERVAL_SECS: u64 = 60;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct FailedEvent {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub event_type: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub failed_at: DateTime<Utc>,
|
||||||
|
pub retry_at: DateTime<Utc>,
|
||||||
|
pub retry_count: i32,
|
||||||
|
pub last_error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PgFailedEventStore {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgFailedEventStore {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a newly exhausted event into the DLQ.
|
||||||
|
pub async fn insert(
|
||||||
|
&self,
|
||||||
|
event_type: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
last_error: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let retry_at = Utc::now() + chrono::Duration::seconds(DLQ_INITIAL_BACKOFF_SECS);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO failed_events \
|
||||||
|
(event_type, payload, retry_at, last_error) \
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
)
|
||||||
|
.bind(event_type)
|
||||||
|
.bind(payload)
|
||||||
|
.bind(retry_at)
|
||||||
|
.bind(last_error)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all events due for retry (retry_at <= now, retry_count < DLQ_MAX_RETRIES).
|
||||||
|
pub async fn poll_due(&self) -> Result<Vec<FailedEvent>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, FailedEvent>(
|
||||||
|
"SELECT id, event_type, payload, failed_at, retry_at, retry_count, last_error \
|
||||||
|
FROM failed_events \
|
||||||
|
WHERE retry_at <= now() AND retry_count < $1 \
|
||||||
|
ORDER BY retry_at \
|
||||||
|
LIMIT 100",
|
||||||
|
)
|
||||||
|
.bind(DLQ_MAX_RETRIES)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance a row after a republish attempt using exponential backoff.
|
||||||
|
/// next_retry = now + initial * 2^retry_count
|
||||||
|
pub async fn advance(&self, id: uuid::Uuid, error: Option<&str>) -> Result<(), sqlx::Error> {
|
||||||
|
let current: i32 =
|
||||||
|
sqlx::query_scalar("SELECT retry_count FROM failed_events WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let new_count = current + 1;
|
||||||
|
let backoff_secs = DLQ_INITIAL_BACKOFF_SECS * (1_i64 << new_count.min(10));
|
||||||
|
let retry_at = Utc::now() + chrono::Duration::seconds(backoff_secs);
|
||||||
|
let last_error = error.unwrap_or("republish succeeded");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE failed_events \
|
||||||
|
SET retry_count = $1, retry_at = $2, last_error = $3 \
|
||||||
|
WHERE id = $4",
|
||||||
|
)
|
||||||
|
.bind(new_count)
|
||||||
|
.bind(retry_at)
|
||||||
|
.bind(last_error)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park a permanently failed event (retry_count >= DLQ_MAX_RETRIES).
|
||||||
|
pub async fn park_permanently(&self, id: uuid::Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
let far_future = Utc::now() + chrono::Duration::days(365);
|
||||||
|
sqlx::query("UPDATE failed_events SET retry_at = $1 WHERE id = $2")
|
||||||
|
.bind(far_future)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
fn visibility_from_str(s: &str) -> domain::models::thought::Visibility {
|
|
||||||
use domain::models::thought::Visibility;
|
|
||||||
match s {
|
|
||||||
"followers" => Visibility::Followers,
|
|
||||||
"unlisted" => Visibility::Unlisted,
|
|
||||||
"direct" => Visibility::Direct,
|
|
||||||
_ => Visibility::Public,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
models::{
|
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
|
||||||
thought::Thought,
|
|
||||||
user::User,
|
|
||||||
},
|
|
||||||
ports::FeedRepository,
|
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
pub struct PgFeedRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
impl PgFeedRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct FeedRow {
|
|
||||||
thought_id: uuid::Uuid,
|
|
||||||
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>,
|
|
||||||
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>,
|
|
||||||
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,
|
|
||||||
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!(
|
|
||||||
"
|
|
||||||
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.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,
|
|
||||||
{viewer_checks}
|
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
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.updated_at,
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
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: r.liked_by_viewer,
|
|
||||||
boosted_by_viewer: r.boosted_by_viewer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl FeedRepository for PgFeedRepository {
|
|
||||||
async fn home_feed(
|
|
||||||
&self,
|
|
||||||
following_ids: &[UserId],
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility != 'direct'",
|
|
||||||
)
|
|
||||||
.bind(&ids)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
|
||||||
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
|
||||||
.bind(&ids)
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn public_feed(
|
|
||||||
&self,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
|
||||||
)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
|
||||||
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn search(
|
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
|
||||||
)
|
|
||||||
.bind(query)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
|
||||||
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
|
||||||
.bind(query)
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tag_feed(
|
|
||||||
&self,
|
|
||||||
tag_name: &str,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'",
|
|
||||||
)
|
|
||||||
.bind(tag_name)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
|
||||||
let sql = format!(
|
|
||||||
"{sel}
|
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'
|
|
||||||
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
|
|
||||||
);
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
|
||||||
.bind(tag_name)
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_feed(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
|
||||||
let uid = user_id.as_uuid();
|
|
||||||
|
|
||||||
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
|
||||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
|
||||||
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
|
||||||
use domain::{
|
|
||||||
models::{
|
|
||||||
thought::{Thought, Visibility},
|
|
||||||
user::User,
|
|
||||||
},
|
|
||||||
ports::{ThoughtRepository, UserRepository},
|
|
||||||
value_objects::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
|
||||||
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 = "./migrations")]
|
|
||||||
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
|
||||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
|
||||||
let repo = PgFeedRepository::new(pool);
|
|
||||||
let result = repo
|
|
||||||
.public_feed(
|
|
||||||
&PageParams {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result.total, 1);
|
|
||||||
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
|
||||||
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
|
||||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
|
||||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
|
||||||
let repo = PgFeedRepository::new(pool);
|
|
||||||
let result = repo
|
|
||||||
.search(
|
|
||||||
"hello world",
|
|
||||||
&PageParams {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.total >= 1);
|
|
||||||
assert!(result
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
420
crates/adapters/postgres/src/feed/mod.rs
Normal file
420
crates/adapters/postgres/src/feed/mod.rs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
use crate::db_error::IntoDbResult;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, Paginated},
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
|
||||||
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgFeedRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgFeedRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct FeedRow {
|
||||||
|
thought_id: uuid::Uuid,
|
||||||
|
t_user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
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: crate::user::UserRow,
|
||||||
|
like_count: i64,
|
||||||
|
boost_count: i64,
|
||||||
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
|
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)?,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeedSqlBuilder<'a> {
|
||||||
|
options: &'a FeedOptions,
|
||||||
|
scope: &'a FeedScope,
|
||||||
|
viewer: Option<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FeedSqlBuilder<'a> {
|
||||||
|
fn new(options: &'a FeedOptions, scope: &'a FeedScope, viewer: Option<uuid::Uuid>) -> Self {
|
||||||
|
Self {
|
||||||
|
options,
|
||||||
|
scope,
|
||||||
|
viewer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(&self, viewer_param: &str) -> String {
|
||||||
|
let (viewer_cols, viewer_joins) = match self.viewer {
|
||||||
|
Some(_) => (
|
||||||
|
"(lv.thought_id IS NOT NULL) AS liked_by_viewer,
|
||||||
|
(bv.thought_id IS NOT NULL) AS boosted_by_viewer".to_string(),
|
||||||
|
format!(
|
||||||
|
"LEFT JOIN (SELECT thought_id FROM likes WHERE user_id={viewer_param}) lv ON lv.thought_id = t.id
|
||||||
|
LEFT JOIN (SELECT thought_id FROM boosts WHERE user_id={viewer_param}) bv ON bv.thought_id = t.id"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
"false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
|
t.in_reply_to_id,
|
||||||
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
|
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
|
||||||
|
t.note_extensions, t.mood,
|
||||||
|
u.id,
|
||||||
|
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||||
|
THEN '@' || ra.handle ||
|
||||||
|
CASE WHEN ra.handle NOT LIKE '%@%'
|
||||||
|
THEN '@' || SPLIT_PART(ra.url, '/', 3)
|
||||||
|
ELSE '' END
|
||||||
|
ELSE u.username END AS username,
|
||||||
|
u.email, u.password_hash,
|
||||||
|
COALESCE(ra.display_name, u.display_name) AS display_name,
|
||||||
|
u.bio,
|
||||||
|
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
||||||
|
u.header_url, u.custom_css, u.profile_fields, u.custom_moods,
|
||||||
|
u.local,
|
||||||
|
u.created_at, u.updated_at,
|
||||||
|
COALESCE(l_agg.cnt, 0) AS like_count,
|
||||||
|
COALESCE(b_agg.cnt, 0) AS boost_count,
|
||||||
|
COALESCE(r_agg.cnt, 0) AS reply_count,
|
||||||
|
{viewer_cols}
|
||||||
|
FROM thoughts t
|
||||||
|
JOIN users u ON u.id=t.user_id
|
||||||
|
LEFT JOIN remote_actors ra ON u.ap_id = ra.url
|
||||||
|
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM likes GROUP BY thought_id) l_agg ON l_agg.thought_id = t.id
|
||||||
|
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM boosts GROUP BY thought_id) b_agg ON b_agg.thought_id = t.id
|
||||||
|
LEFT JOIN (SELECT in_reply_to_id, COUNT(*) AS cnt FROM thoughts WHERE in_reply_to_id IS NOT NULL GROUP BY in_reply_to_id) r_agg ON r_agg.in_reply_to_id = t.id
|
||||||
|
{viewer_joins}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_clause(&self, viewer_param: &str) -> String {
|
||||||
|
match self.viewer {
|
||||||
|
Some(_) => format!(
|
||||||
|
" OR t.user_id IN (
|
||||||
|
SELECT u2.id FROM users u2
|
||||||
|
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||||
|
WHERE ff.local_user_id = {viewer_param}
|
||||||
|
)"
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_sql(&self) -> String {
|
||||||
|
let f = &self.options.filter;
|
||||||
|
let mut s = String::new();
|
||||||
|
if f.originals_only {
|
||||||
|
s += " AND t.in_reply_to_id IS NULL";
|
||||||
|
}
|
||||||
|
if f.replies_only {
|
||||||
|
s += " AND t.in_reply_to_id IS NOT NULL";
|
||||||
|
}
|
||||||
|
if f.local_only {
|
||||||
|
s += " AND t.local = true";
|
||||||
|
}
|
||||||
|
if f.hide_sensitive {
|
||||||
|
s += " AND t.sensitive = false";
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_sql(&self) -> &'static str {
|
||||||
|
if matches!(self.scope, FeedScope::Search { .. }) {
|
||||||
|
return "ORDER BY similarity(t.content, $1) DESC";
|
||||||
|
}
|
||||||
|
match &self.options.sort {
|
||||||
|
FeedSort::Newest => "ORDER BY t.created_at DESC",
|
||||||
|
FeedSort::Oldest => "ORDER BY t.created_at ASC",
|
||||||
|
FeedSort::MostLiked => "ORDER BY like_count DESC, t.created_at DESC",
|
||||||
|
FeedSort::MostBoosted => "ORDER BY boost_count DESC, t.created_at DESC",
|
||||||
|
FeedSort::MostDiscussed => "ORDER BY reply_count DESC, t.created_at DESC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public(&self) -> (String, String) {
|
||||||
|
let filter = self.filter_sql();
|
||||||
|
let order = self.order_sql();
|
||||||
|
let count = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'{}",
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
let data = format!(
|
||||||
|
"{} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
|
||||||
|
self.select("$3"),
|
||||||
|
filter,
|
||||||
|
order
|
||||||
|
);
|
||||||
|
(count, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home(&self) -> (String, String) {
|
||||||
|
let filter = self.filter_sql();
|
||||||
|
let order = self.order_sql();
|
||||||
|
let count = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
|
||||||
|
self.fed_clause("$2"), filter
|
||||||
|
);
|
||||||
|
let data =
|
||||||
|
format!(
|
||||||
|
"{} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
|
||||||
|
self.select("$4"), self.fed_clause("$4"), filter, order
|
||||||
|
);
|
||||||
|
(count, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(&self) -> (String, String) {
|
||||||
|
let filter = self.filter_sql();
|
||||||
|
let order = self.order_sql();
|
||||||
|
let count = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'{}",
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
let data = format!(
|
||||||
|
"{} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
|
||||||
|
self.select("$4"),
|
||||||
|
filter,
|
||||||
|
order
|
||||||
|
);
|
||||||
|
(count, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(&self) -> (String, String) {
|
||||||
|
let filter = self.filter_sql();
|
||||||
|
let order = self.order_sql();
|
||||||
|
let count = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.name = $1 AND t.visibility = 'public'{}",
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
let data = format!(
|
||||||
|
"{}
|
||||||
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
|
||||||
|
self.select("$4"),
|
||||||
|
filter,
|
||||||
|
order
|
||||||
|
);
|
||||||
|
(count, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(&self) -> (String, String) {
|
||||||
|
let filter = self.filter_sql();
|
||||||
|
let order = self.order_sql();
|
||||||
|
let count = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted'))))){}",
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
let data = format!(
|
||||||
|
"{} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
|
||||||
|
self.select("$4"), filter, order
|
||||||
|
);
|
||||||
|
(count, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FeedRepository for PgFeedRepository {
|
||||||
|
async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
|
||||||
|
let page = &req.query.page;
|
||||||
|
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
|
||||||
|
|
||||||
|
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||||
|
|
||||||
|
match &req.query.scope {
|
||||||
|
FeedScope::Home { following_ids } => {
|
||||||
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
|
let (count_sql, data_sql) = builder.home();
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(&ids)
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||||
|
.bind(&ids)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedScope::Public => {
|
||||||
|
let (count_sql, data_sql) = builder.public();
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedScope::Search { query } => {
|
||||||
|
let (count_sql, data_sql) = builder.search();
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(query)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||||
|
.bind(query)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedScope::Tag { tag_name } => {
|
||||||
|
let (count_sql, data_sql) = builder.tag();
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(tag_name)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||||
|
.bind(tag_name)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedScope::User { user_id } => {
|
||||||
|
let uid = user_id.as_uuid();
|
||||||
|
let (count_sql, data_sql) = builder.user();
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.bind(viewer_uuid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
82
crates/adapters/postgres/src/feed/tests.rs
Normal file
82
crates/adapters/postgres/src/feed/tests.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
use domain::{
|
||||||
|
models::{
|
||||||
|
feed::PageParams,
|
||||||
|
thought::{NewThought, Thought, Visibility},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
|
||||||
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||||
|
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 = "./migrations")]
|
||||||
|
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||||
|
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||||
|
let repo = PgFeedRepository::new(pool);
|
||||||
|
let result = repo
|
||||||
|
.query(&FeedRequest {
|
||||||
|
query: FeedQuery::public(
|
||||||
|
PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
options: FeedOptions::default(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.total, 1);
|
||||||
|
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
||||||
|
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||||
|
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||||
|
let repo = PgFeedRepository::new(pool);
|
||||||
|
let result = repo
|
||||||
|
.query(&FeedRequest {
|
||||||
|
query: FeedQuery::search(
|
||||||
|
"hello world",
|
||||||
|
PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
options: FeedOptions::default(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.total >= 1);
|
||||||
|
assert!(result
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||||
|
}
|
||||||
@@ -1,24 +1,7 @@
|
|||||||
|
use crate::db_error::IntoDbResult;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
fn follow_state_from_str(s: &str) -> domain::models::social::FollowState {
|
|
||||||
use domain::models::social::FollowState;
|
|
||||||
match s {
|
|
||||||
"pending" => FollowState::Pending,
|
|
||||||
"rejected" => FollowState::Rejected,
|
|
||||||
_ => FollowState::Accepted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn follow_state_as_str(state: &domain::models::social::FollowState) -> &'static str {
|
|
||||||
use domain::models::social::FollowState;
|
|
||||||
match state {
|
|
||||||
FollowState::Pending => "pending",
|
|
||||||
FollowState::Accepted => "accepted",
|
|
||||||
FollowState::Rejected => "rejected",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
@@ -50,12 +33,12 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
)
|
)
|
||||||
.bind(f.follower_id.as_uuid())
|
.bind(f.follower_id.as_uuid())
|
||||||
.bind(f.following_id.as_uuid())
|
.bind(f.following_id.as_uuid())
|
||||||
.bind(follow_state_as_str(&f.state))
|
.bind(f.state.as_str())
|
||||||
.bind(&f.ap_id)
|
.bind(&f.ap_id)
|
||||||
.bind(f.created_at)
|
.bind(f.created_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.into_domain()
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +48,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(following_id.as_uuid())
|
.bind(following_id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
if r.rows_affected() == 0 {
|
if r.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound);
|
return Err(DomainError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -92,14 +75,19 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(following_id.as_uuid())
|
.bind(following_id.as_uuid())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.into_domain()
|
||||||
.map(|o| o.map(|r| Follow {
|
.and_then(|o| {
|
||||||
|
o.map(|r| {
|
||||||
|
Ok(Follow {
|
||||||
follower_id: UserId::from_uuid(r.follower_id),
|
follower_id: UserId::from_uuid(r.follower_id),
|
||||||
following_id: UserId::from_uuid(r.following_id),
|
following_id: UserId::from_uuid(r.following_id),
|
||||||
state: follow_state_from_str(&r.state),
|
state: FollowState::from_db_str(&r.state)?,
|
||||||
ap_id: r.ap_id,
|
ap_id: r.ap_id,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}))
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_state(
|
async fn update_state(
|
||||||
@@ -111,10 +99,10 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
|
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
|
||||||
.bind(follower_id.as_uuid())
|
.bind(follower_id.as_uuid())
|
||||||
.bind(following_id.as_uuid())
|
.bind(following_id.as_uuid())
|
||||||
.bind(follow_state_as_str(state))
|
.bind(state.as_str())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.into_domain()
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +117,10 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(user_id.as_uuid())
|
.bind(user_id.as_uuid())
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.follower_id=u.id
|
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||||
WHERE f.following_id=$1 AND f.state='accepted'
|
WHERE f.following_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -142,7 +130,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(User::from).collect(),
|
items: rows.into_iter().map(User::from).collect(),
|
||||||
@@ -163,10 +151,10 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(user_id.as_uuid())
|
.bind(user_id.as_uuid())
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.following_id=u.id
|
FROM users u JOIN follows f ON f.following_id=u.id
|
||||||
WHERE f.follower_id=$1 AND f.state='accepted'
|
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -176,7 +164,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(User::from).collect(),
|
items: rows.into_iter().map(User::from).collect(),
|
||||||
@@ -196,82 +184,63 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.bind(user_id.as_uuid())
|
.bind(user_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.into_domain()?;
|
||||||
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_mutual(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows f1
|
||||||
|
WHERE f1.follower_id = $1 AND f1.state = 'accepted'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM follows f2
|
||||||
|
WHERE f2.follower_id = f1.following_id
|
||||||
|
AND f2.following_id = f1.follower_id
|
||||||
|
AND f2.state = 'accepted'
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
|
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
|
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
|
||||||
|
u.created_at, u.updated_at
|
||||||
|
FROM users u
|
||||||
|
JOIN follows f1
|
||||||
|
ON f1.follower_id = $1
|
||||||
|
AND f1.following_id = u.id
|
||||||
|
AND f1.state = 'accepted'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM follows f2
|
||||||
|
WHERE f2.follower_id = u.id
|
||||||
|
AND f2.following_id = $1
|
||||||
|
AND f2.state = 'accepted'
|
||||||
|
)
|
||||||
|
ORDER BY f1.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows.into_iter().map(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::user::PgUserRepository;
|
|
||||||
use chrono::Utc;
|
|
||||||
use domain::ports::UserRepository;
|
|
||||||
use domain::{models::user::User, value_objects::*};
|
|
||||||
|
|
||||||
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 save_and_find_follow(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 = PgFollowRepository::new(pool);
|
|
||||||
let follow = Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
};
|
|
||||||
repo.save(&follow).await.unwrap();
|
|
||||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(found.state, FollowState::Accepted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
|
||||||
async fn update_state(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 = PgFollowRepository::new(pool);
|
|
||||||
let follow = Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: FollowState::Pending,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
};
|
|
||||||
repo.save(&follow).await.unwrap();
|
|
||||||
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(found.state, FollowState::Accepted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
|
||||||
async fn get_accepted_following_ids(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 = PgFollowRepository::new(pool);
|
|
||||||
let follow = Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
};
|
|
||||||
repo.save(&follow).await.unwrap();
|
|
||||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
|
||||||
assert_eq!(ids, vec![bob.id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
140
crates/adapters/postgres/src/follow/tests.rs
Normal file
140
crates/adapters/postgres/src/follow/tests.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::test_helpers::seed_user;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn save_and_find_follow(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn update_state(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Pending,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn get_accepted_following_ids(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(ids, vec![bob.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_mutual_returns_only_mutual_accepted_follows(pool: sqlx::PgPool) {
|
||||||
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
|
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||||
|
let repo = PgFollowRepository::new(pool);
|
||||||
|
let page = domain::models::feed::PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
// alice → bob (accepted), bob → alice (accepted) = friends
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: bob.id.clone(),
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// alice → carol (accepted), carol does NOT follow back = not a friend
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: carol.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
||||||
|
assert_eq!(result.total, 1);
|
||||||
|
assert_eq!(result.items.len(), 1);
|
||||||
|
assert_eq!(result.items[0].id, bob.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_mutual_excludes_pending_follows(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 = PgFollowRepository::new(pool);
|
||||||
|
let page = domain::models::feed::PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
// alice → bob (accepted), bob → alice (PENDING) = NOT a friend
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: bob.id.clone(),
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
state: FollowState::Pending,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
||||||
|
assert_eq!(result.total, 0);
|
||||||
|
assert!(result.items.is_empty());
|
||||||
|
}
|
||||||
20
crates/adapters/postgres/src/jsonb.rs
Normal file
20
crates/adapters/postgres/src/jsonb.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
pub fn parse_name_value(v: Option<serde_json::Value>) -> Vec<(String, String)> {
|
||||||
|
v.and_then(|v| v.as_array().cloned())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.into_iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.get("name")?.as_str()?.to_string();
|
||||||
|
let value = item.get("value")?.as_str()?.to_string();
|
||||||
|
Some((name, value))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serialize_name_value(fields: &[(String, String)]) -> serde_json::Value {
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user