Compare commits
131 Commits
master
...
dbd891d60d
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd891d60d | |||
| baf8b57b6d | |||
| a7a331858d | |||
| 31487882e0 | |||
| a08bb3d698 | |||
| 1d50b54227 | |||
| fce819be7f | |||
| 0e45707d7e | |||
| 82f8772104 | |||
| 8eb59bfac6 | |||
| 62970d519a | |||
| 8602614e7c | |||
| a5ea97bbaa | |||
| fcbd132a78 | |||
| e61e5b4cec | |||
| 171cfe4373 | |||
| 24bfda8458 | |||
| 5c9acdecc1 | |||
| 255ff549a4 | |||
| c67371231e | |||
| 68261c4b2b | |||
| 057ed3ccbf | |||
| 8ef3a300bc | |||
| c8c430fe7f | |||
| ec0e24db8d | |||
| 7cb6b94b08 | |||
| b95cebc799 | |||
| 12adddaa16 | |||
| 44385adb6b | |||
| 7110f30e16 | |||
| aadd876994 | |||
| d3b7ecad15 | |||
| 458feebcdd | |||
| 550865bad4 | |||
| 10c4a66de5 | |||
| 004bfb427b | |||
| e6f4a6256f | |||
| dd7beb7ab4 | |||
| ddd9b17ed7 | |||
| d50c13a2db | |||
| 004f3cd4d2 | |||
| 970f5a1644 | |||
| ecba9267cf | |||
| 4890501512 | |||
| cc9658975f | |||
| 38b4774a63 | |||
| 9b47779e63 | |||
| eb7dbb0aee | |||
| a2cc4fba21 | |||
| 6eba91e699 | |||
| cf94b0ba6c | |||
| 057f10cb69 | |||
| ed744046f4 | |||
| 931894d77a | |||
| 2485869af6 | |||
| b0b3c6a59b | |||
| eaf079069f | |||
| a37c877172 | |||
| 904916d4c1 | |||
| 057fc29abc | |||
| 1fa8389a69 | |||
| 83e87e644b | |||
| 13282fc88e | |||
| 10605bbf2f | |||
| 2d742bdbe3 | |||
| 925856f6b8 | |||
| 114d9f9558 | |||
| 69b55058ce | |||
| e995b29be1 | |||
| c202eded05 | |||
| f9ca5836fb | |||
| 7963278189 | |||
| ff82764eb0 | |||
| cfc8c19175 | |||
| a684c922e0 | |||
| 42d3dbd251 | |||
| c072ee95cd | |||
| 0c7a6fe9be | |||
| 53185efe5e | |||
| 1866eef770 | |||
| 137d1a0c6a | |||
| 4f990afe5e | |||
| fb8c75af72 | |||
| 2524440fe4 | |||
| 6082766935 | |||
| e408a53136 | |||
| 68fe8624cd | |||
| 1127a5946f | |||
| f0b87311e3 | |||
| ea14035062 | |||
| 4ae3af8086 | |||
| e0b0a71f1d | |||
| 5f8e96b9be | |||
| 54bd1c193b | |||
| e0a27c99a4 | |||
| 2080fec347 | |||
| 21b6a04f97 | |||
| ebc612a311 | |||
| c9b389a00c | |||
| 3318635da6 | |||
| 2e702c64cc | |||
| 2cee884fe1 | |||
| a0893b1c69 | |||
| 57232705fe | |||
| 02de6b6f83 | |||
| b599047d98 | |||
| 4eeaea2a14 | |||
| ebf0aaab58 | |||
| a3534317de | |||
| 6e5d0de636 | |||
| bfe6db2215 | |||
| f75e796faf | |||
| c5d262c68f | |||
| 38106ecdb6 | |||
| fb39ea2469 | |||
| adc2102927 | |||
| 134ecdcfb4 | |||
| 2b428b2b0a | |||
| 69608cfc75 | |||
| 02ce3a49b4 | |||
| 1dab9ffbfb | |||
| 9dd04541ac | |||
| fe9655ee96 | |||
| 62ee73e302 | |||
| 80b656341d | |||
| 4b8d1027c1 | |||
| 94a3f414e4 | |||
| 63a7001165 | |||
| 321571aae9 | |||
| 9d6e3298f1 | |||
| 6fd9a76e68 |
29
.env
29
.env
@@ -1,10 +1,27 @@
|
|||||||
POSTGRES_USER=thoughts_user
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=thoughts_db
|
POSTGRES_DB=thoughts
|
||||||
|
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/thoughts"
|
||||||
PREFORK=1
|
JWT_SECRET=secret
|
||||||
AUTH_SECRET=secret
|
|
||||||
BASE_URL=http://0.0.0.0
|
# Public base URL — used for ActivityPub actor URLs and canonical links
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
# CORS_ORIGINS=https://your-nextjs-app.example.com
|
||||||
|
|
||||||
|
# Rate limiting — max requests per minute per IP (disabled by default)
|
||||||
|
# RATE_LIMIT=60
|
||||||
|
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
|
||||||
|
RUST_ENV=development # set to "production" to disable AP debug mode
|
||||||
|
|
||||||
|
# NATS event bus (optional — federation and notifications still work without it,
|
||||||
|
# but events will not be delivered to the worker)
|
||||||
|
NATS_URL=nats://localhost:4222
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=info
|
||||||
31
.env.example
31
.env.example
@@ -1,3 +1,28 @@
|
|||||||
POSTGRES_USER=thoughts_user
|
# Database (PostgreSQL required)
|
||||||
POSTGRES_PASSWORD=postgres
|
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
|
||||||
POSTGRES_DB=thoughts_db
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
|
||||||
|
# Public base URL — used for ActivityPub actor URLs and canonical links
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
# CORS_ORIGINS=https://your-nextjs-app.example.com
|
||||||
|
|
||||||
|
# Rate limiting — max requests per minute per IP (disabled by default)
|
||||||
|
# RATE_LIMIT=60
|
||||||
|
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
|
||||||
|
RUST_ENV=development # set to "production" to disable AP debug mode
|
||||||
|
|
||||||
|
# NATS event bus (optional — federation and notifications still work without it,
|
||||||
|
# but events will not be delivered to the worker)
|
||||||
|
# NATS_URL=nats://localhost:4222
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=info
|
||||||
|
|||||||
@@ -1,41 +1,59 @@
|
|||||||
name: Build and Deploy Thoughts
|
name: deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [master]
|
||||||
- master
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.gabrielkaszewski.dev
|
||||||
|
IMAGE: git.gabrielkaszewski.dev/gkaszewski/thoughts
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy-local:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Create .env file
|
- name: Log in to registry
|
||||||
run: |
|
uses: docker/login-action@v3
|
||||||
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
|
with:
|
||||||
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
|
registry: ${{ env.REGISTRY }}
|
||||||
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env
|
|
||||||
|
|
||||||
- name: Build Docker Images Manually
|
- name: Docker metadata
|
||||||
run: |
|
id: meta
|
||||||
docker build --target runtime -t thoughts-backend:latest ./thoughts-backend
|
uses: docker/metadata-action@v5
|
||||||
docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend
|
with:
|
||||||
docker build -t custom-proxy:latest ./nginx
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Deploy with Docker Compose
|
- name: Build and push
|
||||||
run: |
|
uses: docker/build-push-action@v6
|
||||||
docker compose -f compose.prod.yml down
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
|
deploy:
|
||||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
|
needs: build-and-push
|
||||||
POSTGRES_DB=${{ secrets.POSTGRES_DB }} \
|
runs-on: ubuntu-latest
|
||||||
AUTH_SECRET=${{ secrets.AUTH_SECRET }} \
|
if: github.ref == 'refs/heads/master'
|
||||||
docker compose -f compose.prod.yml up -d
|
steps:
|
||||||
|
- name: Deploy via SSH
|
||||||
docker image prune -f
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.DEPLOY_KEY }}
|
||||||
|
script: |
|
||||||
|
docker pull ${{ env.IMAGE }}:latest
|
||||||
|
docker compose -f /opt/thoughts/docker-compose.yml up -d
|
||||||
|
|||||||
24
.gitea/workflows/lint.yml
Normal file
24
.gitea/workflows/lint.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
52
.gitea/workflows/test.yml
Normal file
52
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Unit tests — no database required.
|
||||||
|
# All business logic is tested via TestStore (in-memory port implementations).
|
||||||
|
unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: unit tests
|
||||||
|
run: |
|
||||||
|
cargo test --workspace \
|
||||||
|
--exclude postgres \
|
||||||
|
--exclude postgres-federation \
|
||||||
|
--exclude postgres-search
|
||||||
|
|
||||||
|
# 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
|
||||||
13
.githooks/pre-commit
Executable file
13
.githooks/pre-commit
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "→ cargo fmt"
|
||||||
|
if ! cargo fmt --all -- --check; then
|
||||||
|
echo " run 'cargo fmt --all' to fix formatting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ cargo clippy"
|
||||||
|
if ! cargo clippy --workspace -- -D warnings; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
backend-codebase.txt
|
.env
|
||||||
frontend-codebase.txt
|
|
||||||
.env
|
/target
|
||||||
|
|||||||
165
API Design.md
165
API Design.md
@@ -1,165 +0,0 @@
|
|||||||
# **Thoughts \- API Design (Version 1\)**
|
|
||||||
|
|
||||||
## **1\. Overview**
|
|
||||||
|
|
||||||
This document specifies the RESTful API for the Thoughts platform.
|
|
||||||
|
|
||||||
* **Base URL:** /api/v1
|
|
||||||
* **Data Format:** All requests and responses will be in JSON format.
|
|
||||||
* **Authentication:** The API uses two primary methods for authentication:
|
|
||||||
1. **JWT (JSON Web Tokens):** For the official web client. The POST /api/v1/auth/login endpoint returns a short-lived JWT. This token must be included in the Authorization: Bearer \<token\> header for all subsequent authenticated requests.
|
|
||||||
2. **API Keys:** For third-party applications. Users can generate long-lived API keys. These keys must be included in the Authorization: ApiKey \<key\> header.
|
|
||||||
|
|
||||||
## **2\. API Endpoints**
|
|
||||||
|
|
||||||
### **Auth Endpoints**
|
|
||||||
|
|
||||||
**POST /auth/register**
|
|
||||||
|
|
||||||
* **Description:** Creates a new user account.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Request Body:**
|
|
||||||
{
|
|
||||||
"username": "frutiger",
|
|
||||||
"email": "aero@example.com",
|
|
||||||
"password": "strongpassword123"
|
|
||||||
}
|
|
||||||
|
|
||||||
* **Success Response:** 201 Created with the new User object (password omitted).
|
|
||||||
* **Error Responses:** 400 Bad Request (invalid input), 409 Conflict (username or email already exists).
|
|
||||||
|
|
||||||
**POST /auth/login**
|
|
||||||
|
|
||||||
* **Description:** Authenticates a user and returns a JWT.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Request Body:**
|
|
||||||
{
|
|
||||||
"username": "frutiger",
|
|
||||||
"password": "strongpassword123"
|
|
||||||
}
|
|
||||||
|
|
||||||
* **Success Response:** 200 OK with a JWT.
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
||||||
}
|
|
||||||
|
|
||||||
* **Error Responses:** 400 Bad Request, 401 Unauthorized.
|
|
||||||
|
|
||||||
### **User & Profile Endpoints**
|
|
||||||
|
|
||||||
**GET /users/{username}**
|
|
||||||
|
|
||||||
* **Description:** Retrieves the public profile of a user.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Success Response:** 200 OK with a public User object.
|
|
||||||
|
|
||||||
**GET /users/me**
|
|
||||||
|
|
||||||
* **Description:** Retrieves the full profile of the currently authenticated user (including private details like email).
|
|
||||||
* **Authentication:** Required (JWT).
|
|
||||||
* **Success Response:** 200 OK with the full User object.
|
|
||||||
|
|
||||||
**PUT /users/me**
|
|
||||||
|
|
||||||
* **Description:** Updates the profile of the currently authenticated user.
|
|
||||||
* **Authentication:** Required (JWT).
|
|
||||||
* **Request Body:**
|
|
||||||
{
|
|
||||||
"displayName": "Frutiger Aero Fan",
|
|
||||||
"bio": "Est. 2004",
|
|
||||||
"avatarUrl": "https://...",
|
|
||||||
"headerUrl": "https://...",
|
|
||||||
"customCss": "body { background: blue; }",
|
|
||||||
"topFriends": \["username1", "username2"\]
|
|
||||||
}
|
|
||||||
|
|
||||||
* **Success Response:** 200 OK with the updated User object.
|
|
||||||
* **Error Responses:** 400 Bad Request.
|
|
||||||
|
|
||||||
### **Thoughts (Posts) Endpoints**
|
|
||||||
|
|
||||||
**POST /thoughts**
|
|
||||||
|
|
||||||
* **Description:** Creates a new thought.
|
|
||||||
* **Authentication:** Required (JWT or API Key).
|
|
||||||
* **Request Body:**
|
|
||||||
{
|
|
||||||
"content": "This is my first thought\! \#welcome"
|
|
||||||
}
|
|
||||||
|
|
||||||
* **Success Response:** 201 Created with the new Thought object.
|
|
||||||
* **Error Responses:** 400 Bad Request (e.g., content \> 128 chars).
|
|
||||||
|
|
||||||
**GET /users/{username}/thoughts**
|
|
||||||
|
|
||||||
* **Description:** Retrieves all thoughts for a specific user, paginated.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Success Response:** 200 OK with an array of Thought objects.
|
|
||||||
|
|
||||||
**DELETE /thoughts/{id}**
|
|
||||||
|
|
||||||
* **Description:** Deletes a thought. The user must be the author.
|
|
||||||
* **Authentication:** Required (JWT or API Key).
|
|
||||||
* **Success Response:** 204 No Content.
|
|
||||||
* **Error Responses:** 403 Forbidden, 404 Not Found.
|
|
||||||
|
|
||||||
### **Social Endpoints**
|
|
||||||
|
|
||||||
**POST /users/{username}/follow**
|
|
||||||
|
|
||||||
* **Description:** Follows a user.
|
|
||||||
* **Authentication:** Required (JWT).
|
|
||||||
* **Success Response:** 204 No Content.
|
|
||||||
* **Error Responses:** 404 Not Found, 409 Conflict (already following).
|
|
||||||
|
|
||||||
**DELETE /users/{username}/follow**
|
|
||||||
|
|
||||||
* **Description:** Unfollows a user.
|
|
||||||
* **Authentication:** Required (JWT).
|
|
||||||
* **Success Response:** 204 No Content.
|
|
||||||
* **Error Responses:** 404 Not Found.
|
|
||||||
|
|
||||||
**GET /feed**
|
|
||||||
|
|
||||||
* **Description:** Retrieves the main feed for the authenticated user, paginated.
|
|
||||||
* **Authentication:** Required (JWT).
|
|
||||||
* **Success Response:** 200 OK with an array of Thought objects from followed users.
|
|
||||||
|
|
||||||
### **Discovery Endpoints**
|
|
||||||
|
|
||||||
**GET /tags/popular**
|
|
||||||
|
|
||||||
* **Description:** Retrieves a list of currently popular tags.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Success Response:** 200 OK with an array of tag strings.
|
|
||||||
|
|
||||||
**GET /tags/{tagName}**
|
|
||||||
|
|
||||||
* **Description:** Retrieves a feed of all thoughts with a specific tag, paginated.
|
|
||||||
* **Authentication:** Public.
|
|
||||||
* **Success Response:** 200 OK with an array of Thought objects.
|
|
||||||
|
|
||||||
## **3\. Data Models**
|
|
||||||
|
|
||||||
**User Object (Public)**
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "frutiger",
|
|
||||||
"displayName": "Frutiger Aero Fan",
|
|
||||||
"bio": "Est. 2004",
|
|
||||||
"avatarUrl": "https://...",
|
|
||||||
"headerUrl": "https://...",
|
|
||||||
"customCss": "body { background: blue; }",
|
|
||||||
"topFriends": \["username1", "username2"\],
|
|
||||||
"joinedAt": "2024-01-01T12:00:00Z"
|
|
||||||
}
|
|
||||||
|
|
||||||
**Thought Object**
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "uuid-v4-string",
|
|
||||||
"authorUsername": "frutiger",
|
|
||||||
"content": "This is my first thought\! \#welcome",
|
|
||||||
"tags": \["welcome"\],
|
|
||||||
"createdAt": "2024-01-01T12:01:00Z"
|
|
||||||
}
|
|
||||||
4840
Cargo.lock
generated
Normal file
4840
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
Normal file
54
Cargo.toml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/domain",
|
||||||
|
"crates/application",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/presentation",
|
||||||
|
"crates/bootstrap",
|
||||||
|
"crates/worker",
|
||||||
|
"crates/adapters/postgres",
|
||||||
|
"crates/adapters/postgres-search",
|
||||||
|
"crates/adapters/postgres-federation",
|
||||||
|
"crates/adapters/activitypub-base",
|
||||||
|
"crates/adapters/activitypub",
|
||||||
|
"crates/adapters/auth",
|
||||||
|
"crates/adapters/nats",
|
||||||
|
"crates/adapters/event-payload",
|
||||||
|
"crates/adapters/event-transport",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
futures = "0.3"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
async-nats = "0.38"
|
||||||
|
async-stream = "0.3"
|
||||||
|
reqwest = { version = "0.13", features = ["json"] }
|
||||||
|
url = { version = "2", features = ["serde"] }
|
||||||
|
|
||||||
|
presentation = { path = "crates/presentation" }
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
api-types = { path = "crates/api-types" }
|
||||||
|
postgres = { path = "crates/adapters/postgres" }
|
||||||
|
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||||
|
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||||
|
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
||||||
|
activitypub = { path = "crates/adapters/activitypub" }
|
||||||
|
auth = { path = "crates/adapters/auth" }
|
||||||
|
nats = { path = "crates/adapters/nats" }
|
||||||
|
event-payload = { path = "crates/adapters/event-payload" }
|
||||||
|
event-transport = { path = "crates/adapters/event-transport" }
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# **Thoughts \- Database Schema (PostgreSQL)**
|
|
||||||
|
|
||||||
## **1\. Overview**
|
|
||||||
|
|
||||||
This document outlines the table structure for the Thoughts platform using PostgreSQL. The design uses UUIDs for primary keys to facilitate decentralization and prevent enumeration attacks. All timestamps are stored with time zones (TIMESTAMPTZ).
|
|
||||||
|
|
||||||
## **2\. Schema Diagram (ERD)**
|
|
||||||
|
|
||||||
\+-------------+ \+--------------+ \+--------------+
|
|
||||||
| users |\<--+--| thoughts |---+--|\> thought\_tags |
|
|
||||||
\+-------------+ | \+--------------+ | \+--------------+
|
|
||||||
| | | ^
|
|
||||||
| | | |
|
|
||||||
| | \+--------------+ | \+--------------+
|
|
||||||
\+--------+--+--|\> follows |\<--+-+--| tags |
|
|
||||||
| | \+--------------+ | \+--------------+
|
|
||||||
| | |
|
|
||||||
v | |
|
|
||||||
\+-------------+ | |
|
|
||||||
| top\_friends |\<-+ |
|
|
||||||
\+-------------+ |
|
|
||||||
| |
|
|
||||||
v |
|
|
||||||
\+-------------+ |
|
|
||||||
| api\_keys |\<--------------------------+
|
|
||||||
\+-------------+
|
|
||||||
|
|
||||||
*(Note: Arrows denote foreign key relationships)*
|
|
||||||
|
|
||||||
## **3\. Table Definitions**
|
|
||||||
|
|
||||||
### **users**
|
|
||||||
|
|
||||||
Stores user account and profile information.
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the user. |
|
|
||||||
| username | VARCHAR(32) | NOT NULL, UNIQUE | The user's handle. |
|
|
||||||
| email | VARCHAR(255) | NOT NULL, UNIQUE | The user's email address. |
|
|
||||||
| password\_hash | TEXT | NOT NULL | Hashed password (using Argon2 or bcrypt). |
|
|
||||||
| display\_name | VARCHAR(50) | NULL | User's public display name. |
|
|
||||||
| bio | VARCHAR(160) | NULL | User's public biography. |
|
|
||||||
| avatar\_url | TEXT | NULL | URL to the user's avatar image. |
|
|
||||||
| header\_url | TEXT | NULL | URL to the user's header image. |
|
|
||||||
| custom\_css | TEXT | NULL | User's custom profile CSS. |
|
|
||||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of account creation. |
|
|
||||||
| updated\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of the last profile update. |
|
|
||||||
|
|
||||||
### **thoughts**
|
|
||||||
|
|
||||||
Stores the content of each post.
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the thought. |
|
|
||||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The ID of the authoring user. |
|
|
||||||
| content | VARCHAR(128) | NOT NULL | The text content of the thought. |
|
|
||||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the thought was posted. |
|
|
||||||
|
|
||||||
### **follows**
|
|
||||||
|
|
||||||
A join table representing the follower/following relationship.
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| follower\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is initiating the follow. |
|
|
||||||
| following\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is being followed. |
|
|
||||||
| | | PRIMARY KEY (follower\_id, following\_id) | Ensures a user can't follow someone twice. |
|
|
||||||
|
|
||||||
### **top\_friends**
|
|
||||||
|
|
||||||
Stores the ordered list of a user's "Top Friends".
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The owner of this "Top Friends" list. |
|
|
||||||
| friend\_id | UUID | NOT NULL, REFERENCES users(id) | The user being displayed as a friend. |
|
|
||||||
| position | SMALLINT | NOT NULL | The order (1-8) of the friend on the list. |
|
|
||||||
| | | PRIMARY KEY (user\_id, friend\_id) | Ensures a user can't be in the list twice. |
|
|
||||||
| | | UNIQUE (user\_id, position) | Ensures positions are not duplicated. |
|
|
||||||
|
|
||||||
### **tags and thought\_tags (for hashtags)**
|
|
||||||
|
|
||||||
* **tags**: Stores unique tag names.
|
|
||||||
* **thought\_tags**: A join table linking thoughts to tags.
|
|
||||||
|
|
||||||
#### **tags**
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| id | SERIAL | PRIMARY KEY | Unique ID for the tag. |
|
|
||||||
| name | VARCHAR(50) | NOT NULL, UNIQUE | The tag name (e.g., "welcome"). |
|
|
||||||
|
|
||||||
#### **thought\_tags**
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| thought\_id | UUID | NOT NULL, REFERENCES thoughts(id) | The ID of the thought. |
|
|
||||||
| tag\_id | INTEGER | NOT NULL, REFERENCES tags(id) | The ID of the tag. |
|
|
||||||
| | | PRIMARY KEY (thought\_id, tag\_id) | Prevents duplicate tags per post. |
|
|
||||||
|
|
||||||
### **api\_keys**
|
|
||||||
|
|
||||||
Stores hashed API keys for users.
|
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
|
||||||
| :---- | :---- | :---- | :---- |
|
|
||||||
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the API key. |
|
|
||||||
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The user who owns this key. |
|
|
||||||
| key\_hash | TEXT | NOT NULL, UNIQUE | The hashed value of the API key. |
|
|
||||||
| name | VARCHAR(50) | NOT NULL | A user-provided name for the key. |
|
|
||||||
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the key was created. |
|
|
||||||
|
|
||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ----- build -----
|
||||||
|
FROM rust:slim-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Cache dependency compilation separately from source
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
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/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
||||||
|
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||||
|
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||||
|
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||||
|
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
|
||||||
|
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||||
|
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||||
|
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||||
|
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||||
|
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||||
|
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||||
|
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||||
|
|
||||||
|
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||||
|
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||||
|
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN cargo fetch
|
||||||
|
|
||||||
|
# Now copy real source and build
|
||||||
|
COPY crates ./crates
|
||||||
|
|
||||||
|
RUN cargo build --release -p bootstrap -p worker
|
||||||
|
|
||||||
|
# ----- runtime -----
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libssl3 \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/thoughts ./thoughts
|
||||||
|
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
|
CMD ["./thoughts"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gabriel Kaszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Thoughts
|
||||||
|
|
||||||
|
A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Short-form posts (thoughts) with replies, boosts, and likes
|
||||||
|
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync
|
||||||
|
- 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 — notifications and AP delivery run in a separate worker process
|
||||||
|
- JWT authentication (Bearer token)
|
||||||
|
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||||
|
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||||
|
- Top friends — pin up to 5 users as highlighted contacts
|
||||||
|
- API keys for third-party client access
|
||||||
|
- Home feed, public feed, and per-user thought timelines
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||||
|
|
||||||
|
```
|
||||||
|
domain — pure types and port trait definitions, no external deps
|
||||||
|
application — use cases and event processing services (business logic)
|
||||||
|
api-types — shared REST API request/response DTOs
|
||||||
|
presentation — Axum HTTP router, OpenAPI spec, composition root for the API process
|
||||||
|
bootstrap — binary: thoughts (API server)
|
||||||
|
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
||||||
|
adapters/
|
||||||
|
auth — JWT issuance and validation, Argon2 password hashing
|
||||||
|
postgres — PostgreSQL repositories for all domain entities
|
||||||
|
postgres-search — PostgreSQL trigram full-text search
|
||||||
|
postgres-federation — PostgreSQL-backed federation repository
|
||||||
|
activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware
|
||||||
|
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
|
||||||
|
nats — NATS transport implementing Transport + MessageSource ports
|
||||||
|
event-payload — shared event serialization DTOs
|
||||||
|
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust stable (1.80+)
|
||||||
|
- PostgreSQL 15+
|
||||||
|
- NATS (optional — federation and notifications still work without it, events queue in-process)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in your values:
|
||||||
|
|
||||||
|
```env
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API server (runs migrations automatically on startup)
|
||||||
|
cargo run -p bootstrap
|
||||||
|
|
||||||
|
# Event worker — federation fan-out and notifications (separate terminal)
|
||||||
|
cargo run -p worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Both processes share the same PostgreSQL database. The worker is optional but required for ActivityPub delivery to remote servers.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests — no database required
|
||||||
|
cargo test -p application
|
||||||
|
|
||||||
|
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All REST endpoints are under the root path. Authentication uses `Authorization: Bearer <token>` obtained from `POST /auth/login`.
|
||||||
|
|
||||||
|
Interactive API documentation is available at runtime:
|
||||||
|
|
||||||
|
- **Swagger UI** — `http://localhost:3000/docs`
|
||||||
|
- **Scalar** — `http://localhost:3000/scalar`
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t thoughts .
|
||||||
|
|
||||||
|
# API server
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||||
|
-e JWT_SECRET=change-me \
|
||||||
|
-e BASE_URL=https://yourdomain.example.com \
|
||||||
|
-e NATS_URL=nats://nats:4222 \
|
||||||
|
thoughts
|
||||||
|
|
||||||
|
# Event worker (same image, different entrypoint)
|
||||||
|
docker run \
|
||||||
|
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||||
|
-e BASE_URL=https://yourdomain.example.com \
|
||||||
|
-e NATS_URL=nats://nats:4222 \
|
||||||
|
--entrypoint ./thoughts-worker \
|
||||||
|
thoughts
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License. See [LICENSE](LICENSE).
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
uvx files-to-prompt thoughts-backend -e toml -e rs -e md --ignore "*target" -o backend-codebase.txt
|
|
||||||
uvx files-to-prompt thoughts-frontend -o frontend-codebase.txt --ignore "*node_modules" --ignore "*.lock"
|
|
||||||
81
compose.yml
81
compose.yml
@@ -1,77 +1,26 @@
|
|||||||
services:
|
services:
|
||||||
database:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:16-alpine
|
||||||
container_name: thoughts-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
backend:
|
|
||||||
container_name: thoughts-backend
|
|
||||||
build:
|
|
||||||
context: ./thoughts-backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- RUST_LOG=info
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
depends_on:
|
|
||||||
database:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
container_name: thoughts-frontend
|
|
||||||
build:
|
|
||||||
context: ./thoughts-frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
NEXT_PUBLIC_API_URL: http://localhost/api
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
|
||||||
|
|
||||||
proxy:
|
|
||||||
container_name: thoughts-proxy
|
|
||||||
image: nginx:stable-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
depends_on:
|
|
||||||
- frontend
|
|
||||||
- backend
|
|
||||||
|
|
||||||
db_test:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: thoughts-db-test
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: thoughts
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432"
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 10s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2-alpine
|
||||||
|
ports:
|
||||||
|
- "4222:4222"
|
||||||
|
- "8222:8222" # monitoring endpoint
|
||||||
|
command: ["--jetstream", "--http_port", "8222"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
|
||||||
|
|||||||
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "activitypub-base"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
|
||||||
|
activitypub_federation = "0.7.0-beta.11"
|
||||||
|
enum_delegate = "0.2"
|
||||||
645
crates/adapters/activitypub-base/src/activities.rs
Normal file
645
crates/adapters/activitypub-base/src/activities.rs
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
25
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
25
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
||||||
|
};
|
||||||
|
use axum::extract::Path;
|
||||||
|
|
||||||
|
use crate::actors::{Person, get_local_actor};
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn actor_handler(
|
||||||
|
Path(username): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
||||||
|
let ap_user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_username(&username)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let db_actor = get_local_actor(ap_user.id, &data).await?;
|
||||||
|
let person = db_actor.into_json(&data).await?;
|
||||||
|
|
||||||
|
Ok(FederationJson(WithContext::new_default(person)))
|
||||||
|
}
|
||||||
327
crates/adapters/activitypub-base/src/actors.rs
Normal file
327
crates/adapters/activitypub-base/src/actors.rs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
fetch::object_id::ObjectId,
|
||||||
|
http_signatures::generate_actor_keypair,
|
||||||
|
kinds::actor::PersonType,
|
||||||
|
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
||||||
|
traits::{Actor, Object},
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::repository::RemoteActor;
|
||||||
|
use crate::user::ApProfileField;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DbActor {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub public_key_pem: String,
|
||||||
|
pub private_key_pem: Option<String>,
|
||||||
|
pub inbox_url: Url,
|
||||||
|
pub outbox_url: Url,
|
||||||
|
pub followers_url: Url,
|
||||||
|
pub following_url: Url,
|
||||||
|
pub ap_id: Url,
|
||||||
|
pub last_refreshed_at: DateTime<Utc>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<Url>,
|
||||||
|
pub banner_url: Option<Url>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub profile_url: Option<Url>,
|
||||||
|
pub attachment: Vec<ApProfileField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ApImageObject {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Endpoints {
|
||||||
|
pub shared_inbox: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileFieldObject {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Person {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: PersonType,
|
||||||
|
id: ObjectId<DbActor>,
|
||||||
|
preferred_username: String,
|
||||||
|
inbox: Url,
|
||||||
|
outbox: Url,
|
||||||
|
followers: Url,
|
||||||
|
following: Url,
|
||||||
|
public_key: PublicKey,
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
icon: Option<ApImageObject>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
url: Option<Url>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
discoverable: Option<bool>,
|
||||||
|
manually_approves_followers: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
updated: Option<DateTime<Utc>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
endpoints: Option<Endpoints>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
image: Option<ApImageObject>,
|
||||||
|
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
also_known_as: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
attachment: Vec<ProfileFieldObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_local_actor(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<DbActor, Error> {
|
||||||
|
let user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
||||||
|
|
||||||
|
let (public_key, private_key) = match data
|
||||||
|
.federation_repo
|
||||||
|
.get_local_actor_keypair(user_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(kp) => kp,
|
||||||
|
None => {
|
||||||
|
let kp = generate_actor_keypair()?;
|
||||||
|
data.federation_repo
|
||||||
|
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
||||||
|
.await?;
|
||||||
|
(kp.public_key, kp.private_key)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||||
|
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
|
||||||
|
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
|
||||||
|
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
|
||||||
|
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
|
||||||
|
|
||||||
|
Ok(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: user.username,
|
||||||
|
public_key_pem: public_key,
|
||||||
|
private_key_pem: Some(private_key),
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
banner_url: user.banner_url,
|
||||||
|
also_known_as: user.also_known_as,
|
||||||
|
profile_url: user.profile_url,
|
||||||
|
attachment: user.attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Object for DbActor {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Kind = Person;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.ap_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
||||||
|
Some(self.last_refreshed_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_from_id(
|
||||||
|
object_id: Url,
|
||||||
|
data: &Data<Self::DataType>,
|
||||||
|
) -> Result<Option<Self>, Self::Error> {
|
||||||
|
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let user = match data.user_repo.find_by_id(user_id).await {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keypair = data
|
||||||
|
.federation_repo
|
||||||
|
.get_local_actor_keypair(user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (public_key, private_key) = match keypair {
|
||||||
|
Some(kp) => (kp.0, Some(kp.1)),
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||||
|
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
|
||||||
|
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
|
||||||
|
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
|
||||||
|
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
|
||||||
|
|
||||||
|
Ok(Some(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: user.username,
|
||||||
|
public_key_pem: public_key,
|
||||||
|
private_key_pem: private_key,
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
profile_url: None,
|
||||||
|
attachment: vec![],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||||
|
let public_key = PublicKey {
|
||||||
|
id: format!("{}#main-key", &self.ap_id),
|
||||||
|
owner: self.ap_id.clone(),
|
||||||
|
public_key_pem: self.public_key_pem.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon = self.avatar_url.map(|url| ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
let image = self.banner_url.map(|url| ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
let profile_url = self.profile_url;
|
||||||
|
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
||||||
|
let attachment: Vec<ProfileFieldObject> = self
|
||||||
|
.attachment
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| ProfileFieldObject {
|
||||||
|
kind: "PropertyValue".to_string(),
|
||||||
|
name: f.name,
|
||||||
|
value: f.value,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let shared_inbox =
|
||||||
|
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
|
||||||
|
|
||||||
|
Ok(Person {
|
||||||
|
kind: Default::default(),
|
||||||
|
id: self.ap_id.clone().into(),
|
||||||
|
preferred_username: self.username.clone(),
|
||||||
|
inbox: self.inbox_url.clone(),
|
||||||
|
outbox: self.outbox_url.clone(),
|
||||||
|
followers: self.followers_url.clone(),
|
||||||
|
following: self.following_url.clone(),
|
||||||
|
public_key,
|
||||||
|
name: Some(self.username.clone()),
|
||||||
|
summary: self.bio.clone(),
|
||||||
|
icon,
|
||||||
|
url: profile_url,
|
||||||
|
discoverable: Some(true),
|
||||||
|
manually_approves_followers: true,
|
||||||
|
updated: Some(self.last_refreshed_at),
|
||||||
|
endpoints: Some(Endpoints { shared_inbox }),
|
||||||
|
image,
|
||||||
|
also_known_as,
|
||||||
|
attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
json: &Self::Kind,
|
||||||
|
expected_domain: &Url,
|
||||||
|
_data: &Data<Self::DataType>,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
verify_domains_match(json.id.inner(), expected_domain)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||||
|
let actor = RemoteActor {
|
||||||
|
url: json.id.inner().to_string(),
|
||||||
|
handle: json.preferred_username.clone(),
|
||||||
|
inbox_url: json.inbox.to_string(),
|
||||||
|
shared_inbox_url: None,
|
||||||
|
display_name: json.name.clone(),
|
||||||
|
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
||||||
|
outbox_url: Some(json.outbox.to_string()),
|
||||||
|
};
|
||||||
|
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||||
|
|
||||||
|
let url_str = json.id.inner().to_string();
|
||||||
|
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
||||||
|
let ap_id = json.id.inner().clone();
|
||||||
|
let inbox_url = json.inbox.clone();
|
||||||
|
let outbox_url = json.outbox.clone();
|
||||||
|
let followers_url = json.followers.clone();
|
||||||
|
let following_url = json.following.clone();
|
||||||
|
|
||||||
|
Ok(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: json.preferred_username.clone(),
|
||||||
|
public_key_pem: json.public_key.public_key_pem,
|
||||||
|
private_key_pem: None,
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
profile_url: None,
|
||||||
|
attachment: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor for DbActor {
|
||||||
|
fn public_key_pem(&self) -> &str {
|
||||||
|
&self.public_key_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
fn private_key_pem(&self) -> Option<String> {
|
||||||
|
self.private_key_pem.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox(&self) -> Url {
|
||||||
|
self.inbox_url.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/actors.rs"]
|
||||||
|
mod tests;
|
||||||
47
crates/adapters/activitypub-base/src/content.rs
Normal file
47
crates/adapters/activitypub-base/src/content.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApObjectHandler: Send + Sync {
|
||||||
|
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
||||||
|
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
||||||
|
async fn get_local_objects_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
||||||
|
|
||||||
|
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
||||||
|
/// Returns (ap_id, object_json, published_at).
|
||||||
|
async fn get_local_objects_page(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
before: Option<DateTime<Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
||||||
|
|
||||||
|
/// Incoming Create activity — persist remote content.
|
||||||
|
async fn on_create(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Incoming Update activity — update existing remote content.
|
||||||
|
async fn on_update(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Incoming Delete activity — remove specific remote content.
|
||||||
|
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Actor unfollowed/was removed — clean up all their remote content.
|
||||||
|
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Total number of locally-authored posts across all users.
|
||||||
|
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
||||||
|
}
|
||||||
49
crates/adapters/activitypub-base/src/data.rs
Normal file
49
crates/adapters/activitypub-base/src/data.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/adapters/activitypub-base/src/error.rs
Normal file
48
crates/adapters/activitypub-base/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self(e.into(), StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self(e.into(), StatusCode::BAD_REQUEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Error {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
std::fmt::Display::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<T> for Error
|
||||||
|
where
|
||||||
|
T: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(t: T) -> Self {
|
||||||
|
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl axum::response::IntoResponse for Error {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = self.1;
|
||||||
|
if status.is_server_error() {
|
||||||
|
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
||||||
|
}
|
||||||
|
let body = if status.is_server_error() {
|
||||||
|
"internal server error".to_string()
|
||||||
|
} else {
|
||||||
|
self.0.to_string()
|
||||||
|
};
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
|
||||||
|
use activitypub_federation::error::Error as FedError;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PermissiveVerifier;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl UrlVerifier for PermissiveVerifier {
|
||||||
|
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
|
||||||
|
|
||||||
|
impl ApFederationConfig {
|
||||||
|
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
||||||
|
let config = if debug {
|
||||||
|
FederationConfig::builder()
|
||||||
|
.domain(&data.domain)
|
||||||
|
.app_data(data)
|
||||||
|
.debug(true)
|
||||||
|
.http_signature_compat(true)
|
||||||
|
.url_verifier(Box::new(PermissiveVerifier))
|
||||||
|
.build()
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
FederationConfig::builder()
|
||||||
|
.domain(&data.domain)
|
||||||
|
.app_data(data)
|
||||||
|
.debug(false)
|
||||||
|
.http_signature_compat(true)
|
||||||
|
.build()
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(Self(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_request_data(&self) -> Data<FederationData> {
|
||||||
|
self.0.to_request_data()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
||||||
|
FederationMiddleware::new(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
130
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
130
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||||
|
use axum::extract::{Path, Query};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PageQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn followers_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let collection_id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||||
|
let total = data
|
||||||
|
.federation_repo
|
||||||
|
.count_followers(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
if let Some(page) = query.page {
|
||||||
|
let page = page.max(1);
|
||||||
|
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||||
|
let followers = data
|
||||||
|
.federation_repo
|
||||||
|
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let has_next = offset + followers.len() < total;
|
||||||
|
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
|
||||||
|
|
||||||
|
let mut obj = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollectionPage",
|
||||||
|
"id": format!("{}?page={}", collection_id, page),
|
||||||
|
"partOf": collection_id,
|
||||||
|
"totalItems": total,
|
||||||
|
"orderedItems": items,
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_next {
|
||||||
|
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FederationJson(obj))
|
||||||
|
} else {
|
||||||
|
Ok(FederationJson(json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": collection_id,
|
||||||
|
"totalItems": total,
|
||||||
|
"first": format!("{}?page=1", collection_id),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn following_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let collection_id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||||
|
let total = data
|
||||||
|
.federation_repo
|
||||||
|
.count_following(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
if let Some(page) = query.page {
|
||||||
|
let page = page.max(1);
|
||||||
|
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||||
|
let following = data
|
||||||
|
.federation_repo
|
||||||
|
.get_following_page(user_id, offset as u32, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let has_next = offset + following.len() < total;
|
||||||
|
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||||
|
|
||||||
|
let mut obj = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollectionPage",
|
||||||
|
"id": format!("{}?page={}", collection_id, page),
|
||||||
|
"partOf": collection_id,
|
||||||
|
"totalItems": total,
|
||||||
|
"orderedItems": items,
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_next {
|
||||||
|
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FederationJson(obj))
|
||||||
|
} else {
|
||||||
|
Ok(FederationJson(json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": collection_id,
|
||||||
|
"totalItems": total,
|
||||||
|
"first": format!("{}?page=1", collection_id),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::inbox::{ActivityData, receive_activity},
|
||||||
|
config::Data,
|
||||||
|
protocol::context::WithContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::activities::InboxActivities;
|
||||||
|
use crate::actors::DbActor;
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn inbox_handler(
|
||||||
|
data: Data<FederationData>,
|
||||||
|
activity_data: ActivityData,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
28
crates/adapters/activitypub-base/src/lib.rs
Normal file
28
crates/adapters/activitypub-base/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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};
|
||||||
80
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
80
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeInfoWellKnown {
|
||||||
|
pub links: Vec<NodeInfoLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeInfoLink {
|
||||||
|
pub rel: String,
|
||||||
|
pub href: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeInfoSoftware {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NodeInfoUsage {
|
||||||
|
pub users: NodeInfoUsers,
|
||||||
|
pub local_posts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeInfoUsers {
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NodeInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub software: NodeInfoSoftware,
|
||||||
|
pub protocols: Vec<String>,
|
||||||
|
pub usage: NodeInfoUsage,
|
||||||
|
pub open_registrations: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn nodeinfo_well_known_handler(
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<Json<NodeInfoWellKnown>, Error> {
|
||||||
|
let href = format!("{}/nodeinfo/2.0", data.base_url);
|
||||||
|
Ok(Json(NodeInfoWellKnown {
|
||||||
|
links: vec![NodeInfoLink {
|
||||||
|
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||||
|
href,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
|
||||||
|
let user_count = data.user_repo.count_users().await.unwrap_or(0);
|
||||||
|
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(NodeInfo {
|
||||||
|
version: "2.0".to_string(),
|
||||||
|
software: NodeInfoSoftware {
|
||||||
|
name: data.software_name.clone(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
},
|
||||||
|
protocols: vec!["activitypub".to_string()],
|
||||||
|
usage: NodeInfoUsage {
|
||||||
|
users: NodeInfoUsers { total: user_count },
|
||||||
|
local_posts,
|
||||||
|
},
|
||||||
|
open_registrations: data.allow_registration,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/nodeinfo.rs"]
|
||||||
|
mod tests;
|
||||||
138
crates/adapters/activitypub-base/src/outbox.rs
Normal file
138
crates/adapters/activitypub-base/src/outbox.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use axum::extract::{Path, Query};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use activitypub_federation::{
|
||||||
|
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
||||||
|
protocol::context::WithContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{activities::CreateActivity, data::FederationData, error::Error};
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OutboxQuery {
|
||||||
|
page: Option<bool>,
|
||||||
|
before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollection {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
total_items: u64,
|
||||||
|
first: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollectionPage {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
part_of: String,
|
||||||
|
ordered_items: Vec<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn outbox_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<OutboxQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<axum::response::Response, Error> {
|
||||||
|
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(uuid)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
||||||
|
|
||||||
|
if query.page.unwrap_or(false) {
|
||||||
|
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
||||||
|
|
||||||
|
let items = data
|
||||||
|
.object_handler
|
||||||
|
.get_local_objects_page(uuid, before, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
|
||||||
|
|
||||||
|
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
|
||||||
|
.parse()
|
||||||
|
.expect("valid url");
|
||||||
|
|
||||||
|
let has_more = items.len() == PAGE_SIZE;
|
||||||
|
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
||||||
|
|
||||||
|
let followers_url = format!("{}/followers", actor_url);
|
||||||
|
let ordered_items: Vec<serde_json::Value> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(ap_id, object, _)| {
|
||||||
|
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||||
|
serde_json::to_value(WithContext::new_default(CreateActivity {
|
||||||
|
id: create_id,
|
||||||
|
kind: CreateType::default(),
|
||||||
|
actor: ObjectId::from(actor_url.clone()),
|
||||||
|
object,
|
||||||
|
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![followers_url.clone()],
|
||||||
|
}))
|
||||||
|
.expect("serializable")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let page_id = match &query.before {
|
||||||
|
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
|
||||||
|
None => format!("{}?page=true", outbox_url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = if has_more {
|
||||||
|
oldest_ts.map(|ts| {
|
||||||
|
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
||||||
|
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||||
|
format!("{}?page=true&before={}", outbox_url, ts_str)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(axum::Json(OrderedCollectionPage {
|
||||||
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
kind: "OrderedCollectionPage".to_string(),
|
||||||
|
id: page_id,
|
||||||
|
part_of: outbox_url,
|
||||||
|
ordered_items,
|
||||||
|
next,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
let total = data
|
||||||
|
.object_handler
|
||||||
|
.get_local_objects_for_user(uuid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
|
||||||
|
.len() as u64;
|
||||||
|
|
||||||
|
Ok(axum::Json(OrderedCollection {
|
||||||
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
kind: "OrderedCollection".to_string(),
|
||||||
|
id: outbox_url.clone(),
|
||||||
|
total_items: total,
|
||||||
|
first: format!("{}?page=true", outbox_url),
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
134
crates/adapters/activitypub-base/src/repository.rs
Normal file
134
crates/adapters/activitypub-base/src/repository.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FollowerStatus {
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FollowingStatus {
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RemoteActor {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub inbox_url: String,
|
||||||
|
pub shared_inbox_url: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Follower {
|
||||||
|
pub actor: RemoteActor,
|
||||||
|
pub status: FollowerStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BlockedDomain {
|
||||||
|
pub domain: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub blocked_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationRepository: Send + Sync {
|
||||||
|
async fn add_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follower_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||||
|
async fn get_followers_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Follower>>;
|
||||||
|
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||||
|
async fn get_following_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RemoteActor>>;
|
||||||
|
async fn update_follower_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn add_following(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor: RemoteActor,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||||
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||||
|
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||||
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
||||||
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
||||||
|
async fn get_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<(String, String)>>;
|
||||||
|
async fn save_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
public_key: String,
|
||||||
|
private_key: String,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||||
|
async fn update_following_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowingStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_following_outbox_url(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize>;
|
||||||
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
|
||||||
|
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
|
||||||
|
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
|
||||||
|
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
|
||||||
|
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||||
|
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||||
|
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
|
||||||
|
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
|
||||||
|
}
|
||||||
1410
crates/adapters/activitypub-base/src/service.rs
Normal file
1410
crates/adapters/activitypub-base/src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
49
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
49
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn person_serializes_with_enriched_fields() {
|
||||||
|
let person = Person {
|
||||||
|
kind: Default::default(),
|
||||||
|
id: "https://example.com/users/1"
|
||||||
|
.parse::<url::Url>()
|
||||||
|
.unwrap()
|
||||||
|
.into(),
|
||||||
|
preferred_username: "alice".to_string(),
|
||||||
|
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
||||||
|
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
||||||
|
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
||||||
|
following: "https://example.com/users/1/following".parse().unwrap(),
|
||||||
|
public_key: PublicKey {
|
||||||
|
id: "https://example.com/users/1#main-key".to_string(),
|
||||||
|
owner: "https://example.com/users/1".parse().unwrap(),
|
||||||
|
public_key_pem: "pem".to_string(),
|
||||||
|
},
|
||||||
|
name: Some("Alice".to_string()),
|
||||||
|
summary: Some("Bio text".to_string()),
|
||||||
|
icon: Some(ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
||||||
|
}),
|
||||||
|
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||||
|
discoverable: Some(true),
|
||||||
|
manually_approves_followers: true,
|
||||||
|
updated: Some(Utc::now()),
|
||||||
|
endpoints: Some(Endpoints {
|
||||||
|
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
||||||
|
}),
|
||||||
|
image: None,
|
||||||
|
also_known_as: vec![],
|
||||||
|
attachment: vec![],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&person).unwrap();
|
||||||
|
assert_eq!(json["discoverable"], true);
|
||||||
|
assert_eq!(json["summary"], "Bio text");
|
||||||
|
assert_eq!(json["icon"]["type"], "Image");
|
||||||
|
assert_eq!(json["manuallyApprovesFollowers"], true);
|
||||||
|
assert!(json.get("updated").is_some());
|
||||||
|
assert!(json.get("endpoints").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
json["endpoints"]["sharedInbox"],
|
||||||
|
"https://example.com/inbox"
|
||||||
|
);
|
||||||
|
}
|
||||||
40
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
40
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nodeinfo_well_known_serializes_correctly() {
|
||||||
|
let doc = NodeInfoWellKnown {
|
||||||
|
links: vec![NodeInfoLink {
|
||||||
|
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||||
|
href: "https://example.com/nodeinfo/2.0".to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&doc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
json["links"][0]["rel"],
|
||||||
|
"http://nodeinfo.diaspora.software/ns/schema/2.0"
|
||||||
|
);
|
||||||
|
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nodeinfo_serializes_camel_case() {
|
||||||
|
let doc = NodeInfo {
|
||||||
|
version: "2.0".to_string(),
|
||||||
|
software: NodeInfoSoftware {
|
||||||
|
name: "my-app".to_string(),
|
||||||
|
version: "0.1.0".to_string(),
|
||||||
|
},
|
||||||
|
protocols: vec!["activitypub".to_string()],
|
||||||
|
usage: NodeInfoUsage {
|
||||||
|
users: NodeInfoUsers { total: 3 },
|
||||||
|
local_posts: 42,
|
||||||
|
},
|
||||||
|
open_registrations: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&doc).unwrap();
|
||||||
|
assert_eq!(json["version"], "2.0");
|
||||||
|
assert_eq!(json["software"]["name"], "my-app");
|
||||||
|
assert_eq!(json["usage"]["users"]["total"], 3);
|
||||||
|
assert_eq!(json["usage"]["localPosts"], 42);
|
||||||
|
assert_eq!(json["openRegistrations"], false);
|
||||||
|
}
|
||||||
51
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
51
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
fn _assert_impl_federation_action_port()
|
||||||
|
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");
|
||||||
|
}
|
||||||
31
crates/adapters/activitypub-base/src/urls.rs
Normal file
31
crates/adapters/activitypub-base/src/urls.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
27
crates/adapters/activitypub-base/src/user.rs
Normal file
27
crates/adapters/activitypub-base/src/user.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApProfileField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApUser {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<Url>,
|
||||||
|
pub banner_url: Option<Url>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub profile_url: Option<Url>,
|
||||||
|
pub attachment: Vec<ApProfileField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApUserRepository: Send + Sync {
|
||||||
|
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
|
||||||
|
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
|
||||||
|
async fn count_users(&self) -> anyhow::Result<usize>;
|
||||||
|
}
|
||||||
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
http::header,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebfingerQuery {
|
||||||
|
resource: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webfinger_handler(
|
||||||
|
Query(query): Query<WebfingerQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<Response, Error> {
|
||||||
|
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||||
|
|
||||||
|
let user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_username(name)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
||||||
|
|
||||||
|
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
|
||||||
|
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
||||||
|
}
|
||||||
16
crates/adapters/activitypub/Cargo.toml
Normal file
16
crates/adapters/activitypub/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "activitypub"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
179
crates/adapters/activitypub/src/handler.rs
Normal file
179
crates/adapters/activitypub/src/handler.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::note::ThoughtNote;
|
||||||
|
use crate::urls::ThoughtsUrls;
|
||||||
|
use activitypub_base::ApObjectHandler;
|
||||||
|
use domain::ports::ActivityPubRepository;
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
pub struct ThoughtsObjectHandler {
|
||||||
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
|
urls: ThoughtsUrls,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThoughtsObjectHandler {
|
||||||
|
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
repo,
|
||||||
|
urls: ThoughtsUrls::new(base_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||||
|
async fn get_local_objects_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||||
|
let uid = UserId::from_uuid(user_id);
|
||||||
|
let entries = self
|
||||||
|
.repo
|
||||||
|
.outbox_entries_for_actor(&uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||||
|
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||||
|
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||||
|
let in_reply_to = e
|
||||||
|
.thought
|
||||||
|
.in_reply_to_id
|
||||||
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
|
let note = ThoughtNote::new_public(
|
||||||
|
note_url.clone(),
|
||||||
|
actor_url,
|
||||||
|
e.thought.content.as_str().to_owned(),
|
||||||
|
e.thought.created_at,
|
||||||
|
in_reply_to,
|
||||||
|
e.thought.sensitive,
|
||||||
|
e.thought.content_warning,
|
||||||
|
followers,
|
||||||
|
);
|
||||||
|
Ok((note_url, serde_json::to_value(¬e)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_local_objects_page(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
before: Option<DateTime<Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||||
|
let uid = UserId::from_uuid(user_id);
|
||||||
|
let entries = self
|
||||||
|
.repo
|
||||||
|
.outbox_page_for_actor(&uid, before, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let created_at = e.thought.created_at;
|
||||||
|
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||||
|
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||||
|
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||||
|
let in_reply_to = e
|
||||||
|
.thought
|
||||||
|
.in_reply_to_id
|
||||||
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
|
let note = ThoughtNote::new_public(
|
||||||
|
note_url.clone(),
|
||||||
|
actor_url,
|
||||||
|
e.thought.content.as_str().to_owned(),
|
||||||
|
created_at,
|
||||||
|
in_reply_to,
|
||||||
|
e.thought.sensitive,
|
||||||
|
e.thought.content_warning,
|
||||||
|
followers,
|
||||||
|
);
|
||||||
|
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_create(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
|
let author_id = self
|
||||||
|
.repo
|
||||||
|
.intern_remote_actor(actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
// Derive visibility from AP addressing conventions.
|
||||||
|
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
|
let in_to = note.to.iter().any(|s| s == as_public);
|
||||||
|
let in_cc = note.cc.iter().any(|s| s == as_public);
|
||||||
|
let has_followers = note.to.iter().any(|s| s.ends_with("/followers"))
|
||||||
|
|| note.cc.iter().any(|s| s.ends_with("/followers"));
|
||||||
|
|
||||||
|
let visibility = if in_to {
|
||||||
|
"public"
|
||||||
|
} else if in_cc {
|
||||||
|
"unlisted"
|
||||||
|
} else if has_followers {
|
||||||
|
"followers"
|
||||||
|
} else {
|
||||||
|
"direct"
|
||||||
|
};
|
||||||
|
|
||||||
|
self.repo
|
||||||
|
.accept_note(
|
||||||
|
ap_id,
|
||||||
|
&author_id,
|
||||||
|
¬e.content,
|
||||||
|
note.published,
|
||||||
|
note.sensitive,
|
||||||
|
note.summary,
|
||||||
|
visibility,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_update(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
|
self.repo
|
||||||
|
.apply_note_update(ap_id, ¬e.content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||||
|
self.repo
|
||||||
|
.retract_note(ap_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||||
|
self.repo
|
||||||
|
.retract_actor_notes(actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_local_posts(&self) -> Result<u64> {
|
||||||
|
self.repo
|
||||||
|
.count_local_notes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/adapters/activitypub/src/lib.rs
Normal file
7
crates/adapters/activitypub/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod handler;
|
||||||
|
pub mod note;
|
||||||
|
pub mod urls;
|
||||||
|
|
||||||
|
pub use handler::ThoughtsObjectHandler;
|
||||||
|
pub use note::ThoughtNote;
|
||||||
|
pub use urls::ThoughtsUrls;
|
||||||
78
crates/adapters/activitypub/src/note.rs
Normal file
78
crates/adapters/activitypub/src/note.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
57
crates/adapters/activitypub/src/urls.rs
Normal file
57
crates/adapters/activitypub/src/urls.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/adapters/auth/Cargo.toml
Normal file
16
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
115
crates/adapters/auth/src/lib.rs
Normal file
115
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
secret: String,
|
||||||
|
ttl_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(secret: String, ttl_seconds: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
secret,
|
||||||
|
ttl_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService for JwtAuthService {
|
||||||
|
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.as_uuid().to_string(),
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(GeneratedToken {
|
||||||
|
token,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| DomainError::Unauthorized)?;
|
||||||
|
let uuid =
|
||||||
|
uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for Argon2PasswordHasher {
|
||||||
|
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
let salt = SaltString::generate(OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
Ok(PasswordHash(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
|
||||||
|
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/adapters/event-payload/Cargo.toml
Normal file
10
crates/adapters/event-payload/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-payload"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
402
crates/adapters/event-payload/src/lib.rs
Normal file
402
crates/adapters/event-payload/src/lib.rs
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Serializable mirror of domain::events::DomainEvent.
|
||||||
|
/// All IDs are Strings (UUID hex) — no domain type dependencies.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum EventPayload {
|
||||||
|
ThoughtCreated {
|
||||||
|
thought_id: String,
|
||||||
|
user_id: String,
|
||||||
|
in_reply_to_id: Option<String>,
|
||||||
|
},
|
||||||
|
ThoughtDeleted {
|
||||||
|
thought_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
ThoughtUpdated {
|
||||||
|
thought_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
LikeAdded {
|
||||||
|
like_id: String,
|
||||||
|
user_id: String,
|
||||||
|
thought_id: String,
|
||||||
|
},
|
||||||
|
LikeRemoved {
|
||||||
|
user_id: String,
|
||||||
|
thought_id: String,
|
||||||
|
},
|
||||||
|
BoostAdded {
|
||||||
|
boost_id: String,
|
||||||
|
user_id: String,
|
||||||
|
thought_id: String,
|
||||||
|
},
|
||||||
|
BoostRemoved {
|
||||||
|
user_id: String,
|
||||||
|
thought_id: String,
|
||||||
|
},
|
||||||
|
FollowRequested {
|
||||||
|
follower_id: String,
|
||||||
|
following_id: String,
|
||||||
|
},
|
||||||
|
FollowAccepted {
|
||||||
|
follower_id: String,
|
||||||
|
following_id: String,
|
||||||
|
},
|
||||||
|
FollowRejected {
|
||||||
|
follower_id: String,
|
||||||
|
following_id: String,
|
||||||
|
},
|
||||||
|
Unfollowed {
|
||||||
|
follower_id: String,
|
||||||
|
following_id: String,
|
||||||
|
},
|
||||||
|
UserBlocked {
|
||||||
|
blocker_id: String,
|
||||||
|
blocked_id: String,
|
||||||
|
},
|
||||||
|
UserUnblocked {
|
||||||
|
blocker_id: String,
|
||||||
|
blocked_id: String,
|
||||||
|
},
|
||||||
|
UserRegistered {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventPayload {
|
||||||
|
/// Returns the NATS subject for this event.
|
||||||
|
pub fn subject(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||||
|
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||||
|
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||||
|
Self::LikeAdded { .. } => "likes.added",
|
||||||
|
Self::LikeRemoved { .. } => "likes.removed",
|
||||||
|
Self::BoostAdded { .. } => "boosts.added",
|
||||||
|
Self::BoostRemoved { .. } => "boosts.removed",
|
||||||
|
Self::FollowRequested { .. } => "follows.requested",
|
||||||
|
Self::FollowAccepted { .. } => "follows.accepted",
|
||||||
|
Self::FollowRejected { .. } => "follows.rejected",
|
||||||
|
Self::Unfollowed { .. } => "follows.removed",
|
||||||
|
Self::UserBlocked { .. } => "users.blocked",
|
||||||
|
Self::UserUnblocked { .. } => "users.unblocked",
|
||||||
|
Self::UserRegistered { .. } => "users.registered",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DomainEvent → EventPayload ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl From<&DomainEvent> for EventPayload {
|
||||||
|
fn from(e: &DomainEvent) -> Self {
|
||||||
|
match e {
|
||||||
|
DomainEvent::ThoughtCreated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
in_reply_to_id,
|
||||||
|
} => Self::ThoughtCreated {
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
|
||||||
|
},
|
||||||
|
DomainEvent::ThoughtDeleted {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => Self::ThoughtDeleted {
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => Self::ThoughtUpdated {
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::LikeAdded {
|
||||||
|
like_id,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => Self::LikeAdded {
|
||||||
|
like_id: like_id.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::LikeRemoved {
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => Self::LikeRemoved {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::BoostAdded {
|
||||||
|
boost_id,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => Self::BoostAdded {
|
||||||
|
boost_id: boost_id.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::BoostRemoved {
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => Self::BoostRemoved {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
thought_id: thought_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::FollowRequested {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => Self::FollowRequested {
|
||||||
|
follower_id: follower_id.to_string(),
|
||||||
|
following_id: following_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::FollowAccepted {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => Self::FollowAccepted {
|
||||||
|
follower_id: follower_id.to_string(),
|
||||||
|
following_id: following_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::FollowRejected {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => Self::FollowRejected {
|
||||||
|
follower_id: follower_id.to_string(),
|
||||||
|
following_id: following_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::Unfollowed {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => Self::Unfollowed {
|
||||||
|
follower_id: follower_id.to_string(),
|
||||||
|
following_id: following_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::UserBlocked {
|
||||||
|
blocker_id,
|
||||||
|
blocked_id,
|
||||||
|
} => Self::UserBlocked {
|
||||||
|
blocker_id: blocker_id.to_string(),
|
||||||
|
blocked_id: blocked_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::UserUnblocked {
|
||||||
|
blocker_id,
|
||||||
|
blocked_id,
|
||||||
|
} => Self::UserUnblocked {
|
||||||
|
blocker_id: blocker_id.to_string(),
|
||||||
|
blocked_id: blocked_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EventPayload → DomainEvent ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
|
||||||
|
uuid::Uuid::parse_str(s)
|
||||||
|
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<EventPayload> for DomainEvent {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||||
|
Ok(match p {
|
||||||
|
EventPayload::ThoughtCreated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
in_reply_to_id,
|
||||||
|
} => DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
in_reply_to_id: in_reply_to_id
|
||||||
|
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
|
||||||
|
.transpose()?,
|
||||||
|
},
|
||||||
|
EventPayload::ThoughtDeleted {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => DomainEvent::ThoughtDeleted {
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::ThoughtUpdated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::LikeAdded {
|
||||||
|
like_id,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => DomainEvent::LikeAdded {
|
||||||
|
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::LikeRemoved {
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => DomainEvent::LikeRemoved {
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::BoostAdded {
|
||||||
|
boost_id,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => DomainEvent::BoostAdded {
|
||||||
|
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::BoostRemoved {
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => DomainEvent::BoostRemoved {
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::FollowRequested {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => DomainEvent::FollowRequested {
|
||||||
|
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||||
|
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::FollowAccepted {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => DomainEvent::FollowAccepted {
|
||||||
|
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||||
|
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::FollowRejected {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => DomainEvent::FollowRejected {
|
||||||
|
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||||
|
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::Unfollowed {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => DomainEvent::Unfollowed {
|
||||||
|
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||||
|
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::UserBlocked {
|
||||||
|
blocker_id,
|
||||||
|
blocked_id,
|
||||||
|
} => DomainEvent::UserBlocked {
|
||||||
|
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||||
|
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::UserUnblocked {
|
||||||
|
blocker_id,
|
||||||
|
blocked_id,
|
||||||
|
} => DomainEvent::UserUnblocked {
|
||||||
|
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||||
|
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||||
|
},
|
||||||
|
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thought_created_roundtrip() {
|
||||||
|
let p = EventPayload::ThoughtCreated {
|
||||||
|
thought_id: "abc".into(),
|
||||||
|
user_id: "def".into(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&p).unwrap();
|
||||||
|
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.subject(), "thoughts.created");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_subjects_are_unique() {
|
||||||
|
let samples: &[EventPayload] = &[
|
||||||
|
EventPayload::ThoughtCreated {
|
||||||
|
thought_id: "a".into(),
|
||||||
|
user_id: "b".into(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
},
|
||||||
|
EventPayload::ThoughtDeleted {
|
||||||
|
thought_id: "a".into(),
|
||||||
|
user_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::ThoughtUpdated {
|
||||||
|
thought_id: "a".into(),
|
||||||
|
user_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::LikeAdded {
|
||||||
|
like_id: "a".into(),
|
||||||
|
user_id: "b".into(),
|
||||||
|
thought_id: "c".into(),
|
||||||
|
},
|
||||||
|
EventPayload::LikeRemoved {
|
||||||
|
user_id: "b".into(),
|
||||||
|
thought_id: "c".into(),
|
||||||
|
},
|
||||||
|
EventPayload::BoostAdded {
|
||||||
|
boost_id: "a".into(),
|
||||||
|
user_id: "b".into(),
|
||||||
|
thought_id: "c".into(),
|
||||||
|
},
|
||||||
|
EventPayload::BoostRemoved {
|
||||||
|
user_id: "b".into(),
|
||||||
|
thought_id: "c".into(),
|
||||||
|
},
|
||||||
|
EventPayload::FollowRequested {
|
||||||
|
follower_id: "a".into(),
|
||||||
|
following_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::FollowAccepted {
|
||||||
|
follower_id: "a".into(),
|
||||||
|
following_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::FollowRejected {
|
||||||
|
follower_id: "a".into(),
|
||||||
|
following_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::Unfollowed {
|
||||||
|
follower_id: "a".into(),
|
||||||
|
following_id: "b".into(),
|
||||||
|
},
|
||||||
|
EventPayload::UserBlocked {
|
||||||
|
blocker_id: "a".into(),
|
||||||
|
blocked_id: "b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||||
|
subjects.sort();
|
||||||
|
subjects.dedup();
|
||||||
|
assert_eq!(
|
||||||
|
subjects.len(),
|
||||||
|
samples.len(),
|
||||||
|
"each event must have a unique subject"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/adapters/event-transport/Cargo.toml
Normal file
15
crates/adapters/event-transport/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-transport"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
event-payload = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
230
crates/adapters/event-transport/src/lib.rs
Normal file
230
crates/adapters/event-transport/src/lib.rs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::{DomainEvent, EventEnvelope},
|
||||||
|
ports::{EventConsumer, EventPublisher},
|
||||||
|
};
|
||||||
|
use event_payload::EventPayload;
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
/// Abstraction over any pub/sub transport backend.
|
||||||
|
/// Implement this for NATS, Kafka, Redis Streams, etc.
|
||||||
|
/// The adapter calls `publish_bytes(subject, bytes)` — subjects come from `EventPayload::subject()`.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Transport: Send + Sync {
|
||||||
|
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes domain events to a transport backend.
|
||||||
|
///
|
||||||
|
/// Converts: `DomainEvent` → `EventPayload` → JSON bytes → `transport.publish_bytes(subject, bytes)`
|
||||||
|
///
|
||||||
|
/// To swap transports (e.g. NATS → Kafka), replace the `T` at the composition root.
|
||||||
|
pub struct EventPublisherAdapter<T: Transport> {
|
||||||
|
transport: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> EventPublisherAdapter<T> {
|
||||||
|
pub fn new(transport: T) -> Self {
|
||||||
|
Self { transport }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||||
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
let payload = EventPayload::from(event);
|
||||||
|
let subject = payload.subject();
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
tracing::debug!(subject, "publishing event");
|
||||||
|
self.transport.publish_bytes(subject, &bytes).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A raw inbound message from a transport backend.
|
||||||
|
/// `ack` and `nack` are transport-level acknowledgements (e.g. Kafka offset commit).
|
||||||
|
/// For at-most-once transports (basic NATS), both are no-ops.
|
||||||
|
pub struct RawMessage {
|
||||||
|
pub subject: String,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstraction over any subscribe/consume backend.
|
||||||
|
pub trait MessageSource: Send + Sync {
|
||||||
|
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserializes raw transport messages into domain `EventEnvelope`s.
|
||||||
|
/// Invalid or unknown messages are skipped with a warning — stream continues.
|
||||||
|
pub struct EventConsumerAdapter<S: MessageSource> {
|
||||||
|
source: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: MessageSource> EventConsumerAdapter<S> {
|
||||||
|
pub fn new(source: S) -> Self {
|
||||||
|
Self { source }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||||
|
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||||
|
use futures::StreamExt;
|
||||||
|
let stream = self.source.messages();
|
||||||
|
Box::pin(stream.filter_map(|result| async move {
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("transport error: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(msg) => {
|
||||||
|
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to deserialize event payload: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let event = match DomainEvent::try_from(payload) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("unknown event type: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(Ok(EventEnvelope {
|
||||||
|
event,
|
||||||
|
ack: msg.ack,
|
||||||
|
nack: msg.nack,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/adapters/nats/Cargo.toml
Normal file
17
crates/adapters/nats/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "nats"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
event-payload = { workspace = true }
|
||||||
|
event-transport = { workspace = true }
|
||||||
|
async-nats = { workspace = true }
|
||||||
|
async-stream = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
192
crates/adapters/nats/src/lib.rs
Normal file
192
crates/adapters/nats/src/lib.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use async_nats::jetstream::{self, stream::Config as StreamConfig, AckKind};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use event_transport::{MessageSource, RawMessage, Transport};
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// Stream name and subjects used by both publisher and consumer.
|
||||||
|
const STREAM_NAME: &str = "THOUGHTS_EVENTS";
|
||||||
|
// Explicit prefixes instead of ">" — NATS WorkQueue retention disallows
|
||||||
|
// the catch-all ">" wildcard without also setting no_ack = true.
|
||||||
|
const STREAM_SUBJECTS: &[&str] = &["thoughts.>", "likes.>", "boosts.>", "follows.>", "users.>"];
|
||||||
|
const CONSUMER_NAME: &str = "worker";
|
||||||
|
// Redelivery timeout: if a message is not acked within this time, NATS redelivers it.
|
||||||
|
const ACK_WAIT_SECS: u64 = 30;
|
||||||
|
// Maximum delivery attempts before the message goes to a dead-letter stream (if configured).
|
||||||
|
const MAX_DELIVER: i64 = 5;
|
||||||
|
|
||||||
|
fn stream_config() -> StreamConfig {
|
||||||
|
StreamConfig {
|
||||||
|
name: STREAM_NAME.to_string(),
|
||||||
|
subjects: STREAM_SUBJECTS.iter().map(|s| s.to_string()).collect(),
|
||||||
|
retention: jetstream::stream::RetentionPolicy::WorkQueue,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the JetStream stream exists. Call once at startup before publishing or consuming.
|
||||||
|
/// Idempotent — safe to call from both bootstrap and worker factories.
|
||||||
|
pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> {
|
||||||
|
let js = jetstream::new(client.clone());
|
||||||
|
js.get_or_create_stream(stream_config())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(format!("JetStream stream setup failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NatsTransport — JetStream publish ──────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct NatsTransport {
|
||||||
|
jetstream: jetstream::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsTransport {
|
||||||
|
pub fn new(client: async_nats::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
jetstream: jetstream::new(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for NatsTransport {
|
||||||
|
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||||
|
self.jetstream
|
||||||
|
.publish(subject.to_string(), bytes.to_vec().into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||||
|
.await // wait for server ack — confirms message is durably stored
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NatsMessageSource — JetStream durable push consumer ────────────────────
|
||||||
|
|
||||||
|
pub struct NatsMessageSource {
|
||||||
|
jetstream: jetstream::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsMessageSource {
|
||||||
|
pub fn new(client: async_nats::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
jetstream: jetstream::new(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageSource for NatsMessageSource {
|
||||||
|
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||||
|
let js = self.jetstream.clone();
|
||||||
|
Box::pin(async_stream::try_stream! {
|
||||||
|
// Ensure stream exists (idempotent).
|
||||||
|
js.get_or_create_stream(stream_config())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let stream = js
|
||||||
|
.get_stream(STREAM_NAME)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
// Durable push consumer — survives worker restarts.
|
||||||
|
let consumer = stream
|
||||||
|
.get_or_create_consumer(
|
||||||
|
CONSUMER_NAME,
|
||||||
|
jetstream::consumer::push::Config {
|
||||||
|
durable_name: Some(CONSUMER_NAME.to_string()),
|
||||||
|
deliver_subject: CONSUMER_NAME.to_string() + ".deliver",
|
||||||
|
ack_policy: jetstream::consumer::AckPolicy::Explicit,
|
||||||
|
ack_wait: std::time::Duration::from_secs(ACK_WAIT_SECS),
|
||||||
|
max_deliver: MAX_DELIVER,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut messages = consumer
|
||||||
|
.messages()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
while let Some(result) = messages.next().await {
|
||||||
|
let msg = result.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
let subject = msg.subject.to_string();
|
||||||
|
let payload = msg.payload.to_vec();
|
||||||
|
|
||||||
|
// Wrap in Arc so both closures can hold a reference.
|
||||||
|
let msg = Arc::new(msg);
|
||||||
|
let msg_nack = Arc::clone(&msg);
|
||||||
|
|
||||||
|
yield RawMessage {
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
ack: Box::new(move || {
|
||||||
|
let m = Arc::clone(&msg);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = m.ack().await {
|
||||||
|
tracing::warn!("NATS ack failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
nack: Box::new(move || {
|
||||||
|
let m = Arc::clone(&msg_nack);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = m.ack_with(AckKind::Nak(None)).await {
|
||||||
|
tracing::warn!("NATS nak failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/postgres-federation/Cargo.toml
Normal file
18
crates/adapters/postgres-federation/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "postgres-federation"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
sqlx = { workspace = true, features = ["migrate"] }
|
||||||
574
crates/adapters/postgres-federation/src/lib.rs
Normal file
574
crates/adapters/postgres-federation/src/lib.rs
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use activitypub_base::{
|
||||||
|
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
|
||||||
|
FollowingStatus, RemoteActor,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PostgresFederationRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresFederationRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_str(s: &FollowerStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
FollowerStatus::Pending => "pending",
|
||||||
|
FollowerStatus::Accepted => "accepted",
|
||||||
|
FollowerStatus::Rejected => "rejected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn str_status(s: &str) -> FollowerStatus {
|
||||||
|
match s {
|
||||||
|
"accepted" => FollowerStatus::Accepted,
|
||||||
|
"rejected" => FollowerStatus::Rejected,
|
||||||
|
_ => FollowerStatus::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_remote_actor(
|
||||||
|
url: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
) -> RemoteActor {
|
||||||
|
RemoteActor {
|
||||||
|
url,
|
||||||
|
handle,
|
||||||
|
inbox_url,
|
||||||
|
shared_inbox_url,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
outbox_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationRepository for PostgresFederationRepository {
|
||||||
|
async fn add_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
|
||||||
|
VALUES($1,$2,$3,$4)
|
||||||
|
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
||||||
|
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id"
|
||||||
|
)
|
||||||
|
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id)
|
||||||
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_follower_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||||
|
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||||
|
)
|
||||||
|
.bind(local_user_id)
|
||||||
|
.bind(remote_actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
remote_actor_url: String,
|
||||||
|
status: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
||||||
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||||
|
FROM federation_followers f
|
||||||
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||||
|
WHERE f.local_user_id=$1"
|
||||||
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
||||||
|
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
||||||
|
status: str_status(&r.status),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_followers_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Follower>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
remote_actor_url: String,
|
||||||
|
status: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
||||||
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||||
|
FROM federation_followers f
|
||||||
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||||
|
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||||
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
||||||
|
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
||||||
|
status: str_status(&r.status),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||||
|
let n: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'"
|
||||||
|
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
remote_actor_url: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||||
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||||
|
FROM federation_followers f
|
||||||
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||||
|
WHERE f.local_user_id=$1 AND f.status='pending'"
|
||||||
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||||
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||||
|
).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_follower_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
|
||||||
|
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
|
||||||
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_following(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor: RemoteActor,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.upsert_remote_actor(actor.clone()).await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url)
|
||||||
|
VALUES($1,$2,$3,$4)
|
||||||
|
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
||||||
|
SET follow_activity_id=EXCLUDED.follow_activity_id"
|
||||||
|
)
|
||||||
|
.bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url)
|
||||||
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||||
|
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||||
|
)
|
||||||
|
.bind(local_user_id)
|
||||||
|
.bind(actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
remote_actor_url: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||||
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||||
|
FROM federation_following f
|
||||||
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||||
|
WHERE f.local_user_id=$1"
|
||||||
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||||
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||||
|
).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_following_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RemoteActor>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
remote_actor_url: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||||
|
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||||
|
FROM federation_following f
|
||||||
|
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||||
|
WHERE f.local_user_id=$1
|
||||||
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||||
|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||||
|
).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||||
|
let n: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1")
|
||||||
|
.bind(local_user_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_following_status(
|
||||||
|
&self,
|
||||||
|
_local_user_id: uuid::Uuid,
|
||||||
|
_remote_actor_url: &str,
|
||||||
|
_status: FollowingStatus,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_following_outbox_url(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||||
|
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
|
||||||
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||||
|
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||||
|
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()"
|
||||||
|
)
|
||||||
|
.bind(&actor.url).bind(&actor.handle).bind(&actor.display_name)
|
||||||
|
.bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||||
|
.bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||||
|
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
url: String,
|
||||||
|
handle: String,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
outbox_url: Option<String>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
|
||||||
|
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
|
||||||
|
map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<(String, String)>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
public_key: Option<String>,
|
||||||
|
private_key: Option<String>,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(row.and_then(|r| match (r.public_key, r.private_key) {
|
||||||
|
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
|
||||||
|
_ => None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
public_key: String,
|
||||||
|
private_key: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&public_key)
|
||||||
|
.bind(&private_key)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: DateTime<Utc>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
|
||||||
|
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(activity_id)
|
||||||
|
.bind(object_url)
|
||||||
|
.bind(actor_url)
|
||||||
|
.bind(announced_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||||
|
let n: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1")
|
||||||
|
.bind(object_url)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING"
|
||||||
|
).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
|
||||||
|
.bind(domain)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
domain: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
blocked_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|rows| {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| BlockedDomain {
|
||||||
|
domain: r.domain,
|
||||||
|
reason: r.reason,
|
||||||
|
blocked_at: r.blocked_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||||
|
let n: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING"
|
||||||
|
).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
|
||||||
|
.bind(local_user_id)
|
||||||
|
.bind(actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC"
|
||||||
|
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||||
|
let n: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2"
|
||||||
|
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgresApUserRepository ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PostgresApUserRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresApUserRepository {
|
||||||
|
pub fn new(pool: PgPool, base_url: String) -> Self {
|
||||||
|
Self { pool, base_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_ap_user(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
) -> ApUser {
|
||||||
|
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
|
||||||
|
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
||||||
|
ApUser {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
bio,
|
||||||
|
avatar_url,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
profile_url,
|
||||||
|
attachment: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApUserRepository for PostgresApUserRepository {
|
||||||
|
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> Result<usize> {
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(n as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/adapters/postgres-search/Cargo.toml
Normal file
16
crates/adapters/postgres-search/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "postgres-search"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
sqlx = { workspace = true, features = ["migrate"] }
|
||||||
|
postgres = { workspace = true }
|
||||||
345
crates/adapters/postgres-search/src/lib.rs
Normal file
345
crates/adapters/postgres-search/src/lib.rs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::models::thought::Visibility;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, PageParams, Paginated},
|
||||||
|
thought::Thought,
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::SearchPort,
|
||||||
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgSearchRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgSearchRepository {
|
||||||
|
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>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
private_key: Option<String>,
|
||||||
|
author_created_at: DateTime<Utc>,
|
||||||
|
author_updated_at: DateTime<Utc>,
|
||||||
|
like_count: i64,
|
||||||
|
boost_count: i64,
|
||||||
|
reply_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEED_SELECT: &str = "
|
||||||
|
SELECT
|
||||||
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
|
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
||||||
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
|
t.created_at AS thought_created_at, t.updated_at,
|
||||||
|
u.id AS author_id, u.username, u.email, u.password_hash,
|
||||||
|
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
|
||||||
|
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
|
||||||
|
u.public_key, u.private_key,
|
||||||
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
||||||
|
|
||||||
|
fn row_to_entry(r: FeedRow) -> 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_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.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,
|
||||||
|
public_key: r.public_key,
|
||||||
|
private_key: r.private_key,
|
||||||
|
created_at: r.author_created_at,
|
||||||
|
updated_at: r.author_updated_at,
|
||||||
|
};
|
||||||
|
FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
liked_by_viewer: false,
|
||||||
|
boosted_by_viewer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct UserRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
local: bool,
|
||||||
|
ap_id: Option<String>,
|
||||||
|
inbox_url: Option<String>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
private_key: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserRow> for User {
|
||||||
|
fn from(r: UserRow) -> Self {
|
||||||
|
User {
|
||||||
|
id: UserId::from_uuid(r.id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.local,
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
inbox_url: r.inbox_url,
|
||||||
|
public_key: r.public_key,
|
||||||
|
private_key: r.private_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_SELECT: &str =
|
||||||
|
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||||
|
custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SearchPort for PgSearchRepository {
|
||||||
|
async fn search_thoughts(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
page: &PageParams,
|
||||||
|
_viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
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 sql = format!(
|
||||||
|
"{FEED_SELECT}
|
||||||
|
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 search_users(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM users u
|
||||||
|
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)",
|
||||||
|
)
|
||||||
|
.bind(query)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"{USER_SELECT}
|
||||||
|
WHERE local=true AND (username % $1 OR display_name % $1)
|
||||||
|
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
||||||
|
LIMIT $2 OFFSET $3"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, UserRow>(&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(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/postgres/Cargo.toml
Normal file
18
crates/adapters/postgres/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "postgres"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
sqlx = { workspace = true, features = ["migrate"] }
|
||||||
55
crates/adapters/postgres/migrations/001_initial_schema.sql
Normal file
55
crates/adapters/postgres/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name VARCHAR(50),
|
||||||
|
bio VARCHAR(160),
|
||||||
|
avatar_url TEXT,
|
||||||
|
header_url TEXT,
|
||||||
|
custom_css TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thoughts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content VARCHAR(128) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS follows (
|
||||||
|
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (follower_id, following_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS top_friends (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
position SMALLINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, friend_id),
|
||||||
|
UNIQUE (user_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thought_tags (
|
||||||
|
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (thought_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS inbox_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS public_key TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS private_key TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE thoughts
|
||||||
|
ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_warning TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE follows
|
||||||
|
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted',
|
||||||
|
ADD COLUMN IF NOT EXISTS ap_id TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
49
crates/adapters/postgres/migrations/003_new_tables.sql
Normal file
49
crates/adapters/postgres/migrations/003_new_tables.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS likes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
|
||||||
|
ap_id TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, thought_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boosts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
|
||||||
|
ap_id TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, thought_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
|
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_actors (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
handle TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
inbox_url TEXT NOT NULL,
|
||||||
|
shared_inbox_url TEXT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
from_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE,
|
||||||
|
read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read);
|
||||||
11
crates/adapters/postgres/migrations/004_search_indexes.sql
Normal file
11
crates/adapters/postgres/migrations/004_search_indexes.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thoughts_content_trgm
|
||||||
|
ON thoughts USING GIN(content gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username_trgm
|
||||||
|
ON users USING GIN(username gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_display_name_trgm
|
||||||
|
ON users USING GIN(display_name gin_trgm_ops)
|
||||||
|
WHERE display_name IS NOT NULL;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them)
|
||||||
|
ALTER TABLE remote_actors
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS outbox_url TEXT;
|
||||||
|
|
||||||
|
-- Federation followers: remote actors following local users
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_followers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
remote_actor_url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
follow_activity_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (local_user_id, remote_actor_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Federation following: local users following remote actors
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_following (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
remote_actor_url TEXT NOT NULL,
|
||||||
|
follow_activity_id TEXT NOT NULL,
|
||||||
|
outbox_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (local_user_id, remote_actor_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Announces (boosts of remote objects via AP)
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_announces (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
activity_id TEXT NOT NULL UNIQUE,
|
||||||
|
object_url TEXT NOT NULL,
|
||||||
|
actor_url TEXT NOT NULL,
|
||||||
|
announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Blocked domains (instance-level)
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_blocked_domains (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
reason TEXT,
|
||||||
|
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Blocked actors (per local user)
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_blocked_actors (
|
||||||
|
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
actor_url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (local_user_id, actor_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url);
|
||||||
292
crates/adapters/postgres/src/activitypub.rs
Normal file
292
crates/adapters/postgres/src/activitypub.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::thought::{Thought, Visibility},
|
||||||
|
ports::{ActivityPubRepository, OutboxEntry},
|
||||||
|
value_objects::{Content, ThoughtId, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct PgActivityPubRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgActivityPubRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityPubRepository for PgActivityPubRepository {
|
||||||
|
async fn outbox_entries_for_actor(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
username: String,
|
||||||
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||||
|
ORDER BY t.created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|rows| {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| OutboxEntry {
|
||||||
|
thought: Thought {
|
||||||
|
id: ThoughtId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
content: Content::new_remote(r.content),
|
||||||
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
|
in_reply_to_url: None,
|
||||||
|
ap_id: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: true,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
},
|
||||||
|
author_username: Username::from_trusted(r.username),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn outbox_page_for_actor(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<DateTime<Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
username: String,
|
||||||
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
let rows = if let Some(before) = before {
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||||
|
ORDER BY t.created_at DESC LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(before)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||||
|
ORDER BY t.created_at DESC LIMIT $2",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(limit as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| OutboxEntry {
|
||||||
|
thought: Thought {
|
||||||
|
id: ThoughtId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
content: Content::new_remote(r.content),
|
||||||
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
|
in_reply_to_url: None,
|
||||||
|
ap_id: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: true,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
},
|
||||||
|
author_username: Username::from_trusted(r.username),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_remote_actor_id(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &Url,
|
||||||
|
) -> Result<Option<UserId>, DomainError> {
|
||||||
|
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||||
|
.bind(actor_ap_url.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(UserId::from_uuid))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
|
||||||
|
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let new_id = uuid::Uuid::new_v4();
|
||||||
|
let handle = actor_ap_url
|
||||||
|
.path()
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.replace('/', "_");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||||
|
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(new_id)
|
||||||
|
.bind(&handle)
|
||||||
|
.bind(format!("{}@remote", new_id))
|
||||||
|
.bind(actor_ap_url.as_str())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
// Re-fetch to get whichever id won the race
|
||||||
|
self.find_remote_actor_id(actor_ap_url)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DomainError::Internal(
|
||||||
|
"intern_remote_actor: insert succeeded but row not found".into(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn accept_note(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
author_id: &UserId,
|
||||||
|
content: &str,
|
||||||
|
published: DateTime<Utc>,
|
||||||
|
sensitive: bool,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
crates/adapters/postgres/src/api_key.rs
Normal file
141
crates/adapters/postgres/src/api_key.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/adapters/postgres/src/block.rs
Normal file
102
crates/adapters/postgres/src/block.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
139
crates/adapters/postgres/src/boost.rs
Normal file
139
crates/adapters/postgres/src/boost.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
392
crates/adapters/postgres/src/feed.rs
Normal file
392
crates/adapters/postgres/src/feed.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::models::thought::Visibility;
|
||||||
|
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>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
private_key: Option<String>,
|
||||||
|
author_created_at: DateTime<Utc>,
|
||||||
|
author_updated_at: DateTime<Utc>,
|
||||||
|
like_count: i64,
|
||||||
|
boost_count: i64,
|
||||||
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
|
let viewer_checks = match viewer {
|
||||||
|
Some(uid) => format!(
|
||||||
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||||
|
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.public_key, u.private_key,
|
||||||
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
|
||||||
|
{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_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.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,
|
||||||
|
public_key: r.public_key,
|
||||||
|
private_key: r.private_key,
|
||||||
|
created_at: r.author_created_at,
|
||||||
|
updated_at: r.author_updated_at,
|
||||||
|
};
|
||||||
|
FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
liked_by_viewer: 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
258
crates/adapters/postgres/src/follow.rs
Normal file
258
crates/adapters/postgres/src/follow.rs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{PageParams, Paginated},
|
||||||
|
social::{Follow, FollowState},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::FollowRepository,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgFollowRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgFollowRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FollowRepository for PgFollowRepository {
|
||||||
|
async fn save(&self, f: &Follow) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO follows(follower_id,following_id,state,ap_id,created_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5)
|
||||||
|
ON CONFLICT(follower_id,following_id) DO UPDATE SET state=EXCLUDED.state,ap_id=EXCLUDED.ap_id"
|
||||||
|
)
|
||||||
|
.bind(f.follower_id.as_uuid())
|
||||||
|
.bind(f.following_id.as_uuid())
|
||||||
|
.bind(f.state.as_str())
|
||||||
|
.bind(&f.ap_id)
|
||||||
|
.bind(f.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
let r = sqlx::query("DELETE FROM follows WHERE follower_id=$1 AND following_id=$2")
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_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,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
) -> Result<Option<Follow>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
follower_id: uuid::Uuid,
|
||||||
|
following_id: uuid::Uuid,
|
||||||
|
state: String,
|
||||||
|
ap_id: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2"
|
||||||
|
)
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| Follow {
|
||||||
|
follower_id: UserId::from_uuid(r.follower_id),
|
||||||
|
following_id: UserId::from_uuid(r.following_id),
|
||||||
|
state: FollowState::from_db_str(&r.state),
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_state(
|
||||||
|
&self,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
state: &FollowState,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_id.as_uuid())
|
||||||
|
.bind(state.as_str())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_followers(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
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.public_key,u.private_key,u.created_at,u.updated_at
|
||||||
|
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||||
|
WHERE f.following_id=$1 AND f.state='accepted'
|
||||||
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.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(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_following(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
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.public_key,u.private_key,u.created_at,u.updated_at
|
||||||
|
FROM users u JOIN follows f ON f.following_id=u.id
|
||||||
|
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||||
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.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(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_following_ids(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<UserId>, DomainError> {
|
||||||
|
let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/adapters/postgres/src/lib.rs
Normal file
13
crates/adapters/postgres/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod activitypub;
|
||||||
|
pub mod api_key;
|
||||||
|
pub mod block;
|
||||||
|
pub mod boost;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod follow;
|
||||||
|
pub mod like;
|
||||||
|
pub mod notification;
|
||||||
|
pub mod remote_actor;
|
||||||
|
pub mod tag;
|
||||||
|
pub mod thought;
|
||||||
|
pub mod top_friend;
|
||||||
|
pub mod user;
|
||||||
139
crates/adapters/postgres/src/like.rs
Normal file
139
crates/adapters/postgres/src/like.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::social::Like,
|
||||||
|
ports::LikeRepository,
|
||||||
|
value_objects::{LikeId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgLikeRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgLikeRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LikeRepository for PgLikeRepository {
|
||||||
|
async fn save(&self, l: &Like) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO likes(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(l.id.as_uuid()).bind(l.user_id.as_uuid()).bind(l.thought_id.as_uuid()).bind(&l.ap_id).bind(l.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 likes 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<Like>, 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 likes 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| Like { id: LikeId::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 likes 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 like_and_count(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgLikeRepository::new(pool);
|
||||||
|
let like = Like {
|
||||||
|
id: LikeId::new(),
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&like).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn unlike(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgLikeRepository::new(pool);
|
||||||
|
let like = Like {
|
||||||
|
id: LikeId::new(),
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&like).await.unwrap();
|
||||||
|
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
crates/adapters/postgres/src/notification.rs
Normal file
180
crates/adapters/postgres/src/notification.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{PageParams, Paginated},
|
||||||
|
notification::{Notification, NotificationType},
|
||||||
|
},
|
||||||
|
ports::NotificationRepository,
|
||||||
|
value_objects::{NotificationId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgNotificationRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgNotificationRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NotificationRepository for PgNotificationRepository {
|
||||||
|
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO notifications(id,user_id,type,from_user_id,thought_id,read,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)"
|
||||||
|
)
|
||||||
|
.bind(n.id.as_uuid()).bind(n.user_id.as_uuid()).bind(n.notification_type.as_str())
|
||||||
|
.bind(n.from_user_id.as_ref().map(|u| u.as_uuid()))
|
||||||
|
.bind(n.thought_id.as_ref().map(|t| t.as_uuid()))
|
||||||
|
.bind(n.read).bind(n.created_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<Notification>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1")
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
r#type: String,
|
||||||
|
from_user_id: Option<uuid::Uuid>,
|
||||||
|
thought_id: Option<uuid::Uuid>,
|
||||||
|
read: bool,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset())
|
||||||
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| Notification {
|
||||||
|
id: NotificationId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
notification_type: NotificationType::from_db_str(&r.r#type),
|
||||||
|
from_user_id: r.from_user_id.map(UserId::from_uuid),
|
||||||
|
thought_id: r.thought_id.map(ThoughtId::from_uuid),
|
||||||
|
read: r.read,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE notifications SET read=true 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(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1")
|
||||||
|
.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::{notification::NotificationType, 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_list(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgNotificationRepository::new(pool);
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let n = Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
notification_type: NotificationType::Like,
|
||||||
|
from_user_id: None,
|
||||||
|
thought_id: None,
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&n).await.unwrap();
|
||||||
|
let page = repo
|
||||||
|
.list_for_user(
|
||||||
|
&user.id,
|
||||||
|
&PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page.total, 1);
|
||||||
|
assert!(!page.items[0].read);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgNotificationRepository::new(pool);
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let n = Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
notification_type: NotificationType::Follow,
|
||||||
|
from_user_id: None,
|
||||||
|
thought_id: None,
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
repo.save(&n).await.unwrap();
|
||||||
|
repo.mark_all_read(&user.id).await.unwrap();
|
||||||
|
let page = repo
|
||||||
|
.list_for_user(
|
||||||
|
&user.id,
|
||||||
|
&PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(page.items[0].read);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/adapters/postgres/src/remote_actor.rs
Normal file
50
crates/adapters/postgres/src/remote_actor.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgRemoteActorRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgRemoteActorRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||||
|
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||||
|
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||||
|
public_key=EXCLUDED.public_key,avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||||
|
)
|
||||||
|
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url)
|
||||||
|
.bind(&a.shared_inbox_url).bind(&a.public_key).bind(&a.avatar_url).bind(a.last_fetched_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
url: String,
|
||||||
|
handle: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
inbox_url: String,
|
||||||
|
shared_inbox_url: Option<String>,
|
||||||
|
public_key: String,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
last_fetched_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||||
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at }))
|
||||||
|
}
|
||||||
|
}
|
||||||
180
crates/adapters/postgres/src/tag.rs
Normal file
180
crates/adapters/postgres/src/tag.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{PageParams, Paginated},
|
||||||
|
tag::Tag,
|
||||||
|
thought::Thought,
|
||||||
|
},
|
||||||
|
ports::TagRepository,
|
||||||
|
value_objects::ThoughtId,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgTagRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgTagRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TagRepository for PgTagRepository {
|
||||||
|
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
|
||||||
|
let name = name.to_lowercase();
|
||||||
|
sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING")
|
||||||
|
.bind(&name)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1")
|
||||||
|
.bind(&name)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(Tag {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_to_thought(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
tag_id: i32,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(thought_id.as_uuid())
|
||||||
|
.bind(tag_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1")
|
||||||
|
.bind(thought_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
|
||||||
|
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_thoughts_by_tag(
|
||||||
|
&self,
|
||||||
|
tag_name: &str,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<Thought>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1",
|
||||||
|
)
|
||||||
|
.bind(tag_name)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>(
|
||||||
|
"SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at
|
||||||
|
FROM thoughts th JOIN thought_tags tt ON tt.thought_id=th.id JOIN tags t ON t.id=tt.tag_id
|
||||||
|
WHERE t.name=$1 ORDER BY th.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
).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(Thought::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||||
|
sqlx::query_as::<_, (String, i64)>(
|
||||||
|
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
|
||||||
|
FROM tags t
|
||||||
|
JOIN thought_tags tt ON t.id = tt.tag_id
|
||||||
|
GROUP BY t.id, t.name
|
||||||
|
ORDER BY thought_count DESC
|
||||||
|
LIMIT $1",
|
||||||
|
)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
use domain::ports::{ThoughtRepository, UserRepository};
|
||||||
|
use domain::{
|
||||||
|
models::{
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgTagRepository::new(pool);
|
||||||
|
let t1 = repo.find_or_create("rust").await.unwrap();
|
||||||
|
let t2 = repo.find_or_create("rust").await.unwrap();
|
||||||
|
assert_eq!(t1.id, t2.id);
|
||||||
|
assert_eq!(t1.name, "rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||||
|
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();
|
||||||
|
let repo = PgTagRepository::new(pool);
|
||||||
|
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||||
|
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
|
||||||
|
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
assert_eq!(tags[0].name, "greetings");
|
||||||
|
}
|
||||||
|
}
|
||||||
278
crates/adapters/postgres/src/thought.rs
Normal file
278
crates/adapters/postgres/src/thought.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{PageParams, Paginated},
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
},
|
||||||
|
ports::ThoughtRepository,
|
||||||
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgThoughtRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgThoughtRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct ThoughtRow {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
pub in_reply_to_url: Option<String>,
|
||||||
|
pub ap_id: Option<String>,
|
||||||
|
pub visibility: String,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub local: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ThoughtRow> for Thought {
|
||||||
|
fn from(r: ThoughtRow) -> Self {
|
||||||
|
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: r.in_reply_to_url,
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
visibility: Visibility::from_db_str(&r.visibility),
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: r.local,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const THOUGHT_SELECT: &str =
|
||||||
|
"SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ThoughtRepository for PgThoughtRepository {
|
||||||
|
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
||||||
|
)
|
||||||
|
.bind(t.id.as_uuid())
|
||||||
|
.bind(t.user_id.as_uuid())
|
||||||
|
.bind(t.content.as_str())
|
||||||
|
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
|
||||||
|
.bind(&t.in_reply_to_url)
|
||||||
|
.bind(&t.ap_id)
|
||||||
|
.bind(t.visibility.as_str())
|
||||||
|
.bind(&t.content_warning)
|
||||||
|
.bind(t.sensitive)
|
||||||
|
.bind(t.local)
|
||||||
|
.bind(t.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError> {
|
||||||
|
sqlx::query_as::<_, ThoughtRow>(&format!("{THOUGHT_SELECT} WHERE id=$1"))
|
||||||
|
.bind(id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(Thought::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
let r = sqlx::query("DELETE FROM thoughts 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()))?;
|
||||||
|
if r.rows_affected() == 0 {
|
||||||
|
return Err(DomainError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE id=$1")
|
||||||
|
.bind(id.as_uuid())
|
||||||
|
.bind(content.as_str())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
|
||||||
|
// Recursive CTE: fetches the root thought and all nested replies at any depth.
|
||||||
|
sqlx::query_as::<_, ThoughtRow>(
|
||||||
|
"WITH RECURSIVE thread AS (
|
||||||
|
SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,
|
||||||
|
visibility,content_warning,sensitive,local,created_at,updated_at
|
||||||
|
FROM thoughts WHERE id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,t.in_reply_to_url,t.ap_id,
|
||||||
|
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
||||||
|
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||||
|
)
|
||||||
|
SELECT * FROM thread ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_by_user(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<Thought>, DomainError> {
|
||||||
|
let uid = user_id.as_uuid();
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1")
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ThoughtRow>(&format!(
|
||||||
|
"{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
))
|
||||||
|
.bind(uid)
|
||||||
|
.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(Thought::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::user::PgUserRepository;
|
||||||
|
use domain::ports::UserRepository;
|
||||||
|
use domain::{
|
||||||
|
models::{
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
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_thought(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let repo = PgThoughtRepository::new(pool);
|
||||||
|
let t = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
user.id.clone(),
|
||||||
|
Content::new_local("hello world").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
repo.save(&t).await.unwrap();
|
||||||
|
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.content.as_str(), "hello world");
|
||||||
|
assert!(found.local);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_thought(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
|
let repo = PgThoughtRepository::new(pool);
|
||||||
|
let t = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
user.id.clone(),
|
||||||
|
Content::new_local("bye").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
repo.save(&t).await.unwrap();
|
||||||
|
repo.delete(&t.id, &user.id).await.unwrap();
|
||||||
|
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_wrong_owner_returns_not_found(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 = PgThoughtRepository::new(pool);
|
||||||
|
let t = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("secret").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
repo.save(&t).await.unwrap();
|
||||||
|
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
||||||
|
let repo = PgThoughtRepository::new(pool);
|
||||||
|
let root = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
user.id.clone(),
|
||||||
|
Content::new_local("root").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let reply = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
user.id.clone(),
|
||||||
|
Content::new_local("reply").unwrap(),
|
||||||
|
Some(root.id.clone()),
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
repo.save(&root).await.unwrap();
|
||||||
|
repo.save(&reply).await.unwrap();
|
||||||
|
let thread = repo.get_thread(&root.id).await.unwrap();
|
||||||
|
assert_eq!(thread.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
crates/adapters/postgres/src/top_friend.rs
Normal file
168
crates/adapters/postgres/src/top_friend.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{top_friend::TopFriend, user::User},
|
||||||
|
ports::TopFriendRepository,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgTopFriendRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgTopFriendRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TopFriendRepository for PgTopFriendRepository {
|
||||||
|
async fn set_top_friends(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
friends: Vec<(UserId, i16)>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
sqlx::query("DELETE FROM top_friends WHERE user_id=$1")
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
for (friend_id, pos) in friends {
|
||||||
|
sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)")
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(friend_id.as_uuid())
|
||||||
|
.bind(pos)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
tf_user_id: uuid::Uuid,
|
||||||
|
friend_id: uuid::Uuid,
|
||||||
|
position: i16,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
local: bool,
|
||||||
|
ap_id: Option<String>,
|
||||||
|
inbox_url: Option<String>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
private_key: Option<String>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||||
|
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.public_key, u.private_key, u.created_at, u.updated_at
|
||||||
|
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||||
|
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
use domain::value_objects::{Email, PasswordHash, Username};
|
||||||
|
let tf = TopFriend {
|
||||||
|
user_id: UserId::from_uuid(r.tf_user_id),
|
||||||
|
friend_id: UserId::from_uuid(r.friend_id),
|
||||||
|
position: r.position,
|
||||||
|
};
|
||||||
|
let u = User {
|
||||||
|
id: UserId::from_uuid(r.id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.local,
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
inbox_url: r.inbox_url,
|
||||||
|
public_key: r.public_key,
|
||||||
|
private_key: r.private_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
(tf, u)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::user::PgUserRepository;
|
||||||
|
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 set_and_list_top_friends(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 = PgTopFriendRepository::new(pool);
|
||||||
|
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(friends.len(), 1);
|
||||||
|
assert_eq!(friends[0].0.position, 1);
|
||||||
|
assert_eq!(friends[0].1.username.as_str(), "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn replace_top_friends(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 = PgTopFriendRepository::new(pool);
|
||||||
|
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(friends.len(), 1);
|
||||||
|
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||||
|
}
|
||||||
|
}
|
||||||
276
crates/adapters/postgres/src/user.rs
Normal file
276
crates/adapters/postgres/src/user.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{feed::UserSummary, user::User},
|
||||||
|
ports::UserRepository,
|
||||||
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PgUserRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
impl PgUserRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct UserRow {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub local: bool,
|
||||||
|
pub ap_id: Option<String>,
|
||||||
|
pub inbox_url: Option<String>,
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
pub private_key: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserRow> for User {
|
||||||
|
fn from(r: UserRow) -> Self {
|
||||||
|
User {
|
||||||
|
id: UserId::from_uuid(r.id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.local,
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
inbox_url: r.inbox_url,
|
||||||
|
public_key: r.public_key,
|
||||||
|
private_key: r.private_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_SELECT: &str = "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for PgUserRepository {
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
|
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
|
||||||
|
.bind(id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(User::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
|
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE username=$1"))
|
||||||
|
.bind(username.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(User::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE email=$1"))
|
||||||
|
.bind(email.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(User::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
username=EXCLUDED.username, email=EXCLUDED.email,
|
||||||
|
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
||||||
|
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
||||||
|
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
||||||
|
local=EXCLUDED.local, ap_id=EXCLUDED.ap_id, inbox_url=EXCLUDED.inbox_url,
|
||||||
|
public_key=EXCLUDED.public_key, private_key=EXCLUDED.private_key,
|
||||||
|
updated_at=NOW()"
|
||||||
|
)
|
||||||
|
.bind(user.id.as_uuid())
|
||||||
|
.bind(user.username.as_str())
|
||||||
|
.bind(user.email.as_str())
|
||||||
|
.bind(&user.password_hash.0)
|
||||||
|
.bind(&user.display_name)
|
||||||
|
.bind(&user.bio)
|
||||||
|
.bind(&user.avatar_url)
|
||||||
|
.bind(&user.header_url)
|
||||||
|
.bind(&user.custom_css)
|
||||||
|
.bind(user.local)
|
||||||
|
.bind(&user.ap_id)
|
||||||
|
.bind(&user.inbox_url)
|
||||||
|
.bind(&user.public_key)
|
||||||
|
.bind(&user.private_key)
|
||||||
|
.bind(user.created_at)
|
||||||
|
.bind(user.updated_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(bio)
|
||||||
|
.bind(avatar_url)
|
||||||
|
.bind(header_url)
|
||||||
|
.bind(custom_css)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
thought_count: i64,
|
||||||
|
follower_count: i64,
|
||||||
|
following_count: i64,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
|
||||||
|
COUNT(DISTINCT t.id) AS thought_count,
|
||||||
|
COUNT(DISTINCT f1.follower_id) AS follower_count,
|
||||||
|
COUNT(DISTINCT f2.following_id) AS following_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
|
||||||
|
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
|
||||||
|
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
|
||||||
|
WHERE u.local=true
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| UserSummary {
|
||||||
|
id: UserId::from_uuid(r.id),
|
||||||
|
username: r.username,
|
||||||
|
display_name: r.display_name,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
bio: r.bio,
|
||||||
|
thought_count: r.thought_count,
|
||||||
|
follower_count: r.follower_count,
|
||||||
|
following_count: r.following_count,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn save_and_find_by_id(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgUserRepository::new(pool);
|
||||||
|
let user = User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("alice").unwrap(),
|
||||||
|
Email::new("alice@ex.com").unwrap(),
|
||||||
|
PasswordHash("hash".into()),
|
||||||
|
);
|
||||||
|
repo.save(&user).await.unwrap();
|
||||||
|
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.username.as_str(), "alice");
|
||||||
|
assert_eq!(found.email.as_str(), "alice@ex.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgUserRepository::new(pool);
|
||||||
|
let result = repo
|
||||||
|
.find_by_username(&Username::new("ghost").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn find_by_email(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgUserRepository::new(pool);
|
||||||
|
let user = User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("bob").unwrap(),
|
||||||
|
Email::new("bob@ex.com").unwrap(),
|
||||||
|
PasswordHash("hash".into()),
|
||||||
|
);
|
||||||
|
repo.save(&user).await.unwrap();
|
||||||
|
let found = repo
|
||||||
|
.find_by_email(&Email::new("bob@ex.com").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgUserRepository::new(pool);
|
||||||
|
let user = User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("charlie").unwrap(),
|
||||||
|
Email::new("charlie@ex.com").unwrap(),
|
||||||
|
PasswordHash("hash".into()),
|
||||||
|
);
|
||||||
|
repo.save(&user).await.unwrap();
|
||||||
|
repo.update_profile(
|
||||||
|
&user.id,
|
||||||
|
Some("Charlie".into()),
|
||||||
|
Some("bio".into()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
||||||
|
assert_eq!(found.bio.as_deref(), Some("bio"));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/api-types/Cargo.toml
Normal file
10
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-types"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }
|
||||||
2
crates/api-types/src/lib.rs
Normal file
2
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod requests;
|
||||||
|
pub mod responses;
|
||||||
88
crates/api-types/src/requests.rs
Normal file
88
crates/api-types/src/requests.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
/// Username (1-32 chars, alphanumeric + underscore)
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateThoughtRequest {
|
||||||
|
/// Up to 128 characters
|
||||||
|
pub content: String,
|
||||||
|
pub in_reply_to_id: Option<Uuid>,
|
||||||
|
/// One of: "public", "followers", "unlisted", "direct"
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditThoughtRequest {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateProfileRequest {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetTopFriendsRequest {
|
||||||
|
/// Ordered list of user UUIDs, max 8
|
||||||
|
pub friend_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct PaginationQuery {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub per_page: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaginationQuery {
|
||||||
|
pub fn page(&self) -> u64 {
|
||||||
|
self.page.unwrap_or(1).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn per_page(&self) -> u64 {
|
||||||
|
self.per_page.unwrap_or(20).min(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub q: String,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub per_page: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FollowRemoteRequest {
|
||||||
|
pub handle: String,
|
||||||
|
}
|
||||||
98
crates/api-types/src/responses.rs
Normal file
98
crates/api-types/src/responses.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub local: bool,
|
||||||
|
pub is_followed_by_viewer: bool,
|
||||||
|
#[serde(rename = "joinedAt")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThoughtResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub author: UserResponse,
|
||||||
|
#[serde(rename = "replyToId")]
|
||||||
|
pub in_reply_to_id: Option<Uuid>,
|
||||||
|
pub visibility: String,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub like_count: i64,
|
||||||
|
pub boost_count: i64,
|
||||||
|
pub reply_count: i64,
|
||||||
|
pub liked_by_viewer: bool,
|
||||||
|
pub boosted_by_viewer: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PagedResponse<T: Serialize + utoipa::ToSchema> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u64,
|
||||||
|
pub per_page: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiKeyResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NotificationResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub notification_type: String,
|
||||||
|
pub from_user: Option<UserResponse>,
|
||||||
|
pub thought_id: Option<Uuid>,
|
||||||
|
pub read: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreatedApiKeyResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
/// Raw API key — shown only once at creation
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RemoteActorResponse {
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
17
crates/application/Cargo.toml
Normal file
17
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
2
crates/application/src/lib.rs
Normal file
2
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod services;
|
||||||
|
pub mod use_cases;
|
||||||
534
crates/application/src/services/federation_event.rs
Normal file
534
crates/application/src/services/federation_event.rs
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::thought::{Thought, Visibility},
|
||||||
|
ports::{OutboundFederationPort, ThoughtRepository, UserRepository},
|
||||||
|
value_objects::ThoughtId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct FederationEventService {
|
||||||
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub ap: Arc<dyn OutboundFederationPort>,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationEventService {
|
||||||
|
fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> String {
|
||||||
|
thought
|
||||||
|
.ap_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, thought_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
match event {
|
||||||
|
DomainEvent::ThoughtCreated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t)
|
||||||
|
if t.local
|
||||||
|
&& matches!(
|
||||||
|
t.visibility,
|
||||||
|
Visibility::Public | Visibility::Unlisted
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
t
|
||||||
|
}
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
let user = match self.users.find_by_id(user_id).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
self.ap
|
||||||
|
.broadcast_create(user_id, &thought, user.username.as_str())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::ThoughtDeleted {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => {
|
||||||
|
// No DB lookup — thought is already deleted when this event fires.
|
||||||
|
// No locality guard: delete commands only reach local thoughts via the use case.
|
||||||
|
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
|
||||||
|
self.ap.broadcast_delete(user_id, &ap_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t)
|
||||||
|
if t.local
|
||||||
|
&& matches!(
|
||||||
|
t.visibility,
|
||||||
|
Visibility::Public | Visibility::Unlisted
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
t
|
||||||
|
}
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
let user = match self.users.find_by_id(user_id).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
self.ap
|
||||||
|
.broadcast_update(user_id, &thought, user.username.as_str())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::BoostAdded {
|
||||||
|
boost_id: _,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let object_ap_id = self.object_ap_id(&thought, thought_id);
|
||||||
|
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::BoostRemoved {
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let object_ap_id = self.object_ap_id(&thought, thought_id);
|
||||||
|
self.ap
|
||||||
|
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::thought::{Thought, Visibility},
|
||||||
|
models::user::User,
|
||||||
|
ports::OutboundFederationPort,
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// ── Spy port ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SpyPort {
|
||||||
|
created: Mutex<Vec<ThoughtId>>,
|
||||||
|
deleted: Mutex<Vec<String>>,
|
||||||
|
updated: Mutex<Vec<ThoughtId>>,
|
||||||
|
announced: Mutex<Vec<String>>,
|
||||||
|
undo_announced: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OutboundFederationPort for SpyPort {
|
||||||
|
async fn broadcast_create(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
thought: &Thought,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.created.lock().unwrap().push(thought.id.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
self.deleted.lock().unwrap().push(ap_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn broadcast_update(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
thought: &Thought,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.updated.lock().unwrap().push(thought.id.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||||
|
self.announced.lock().unwrap().push(ap_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn broadcast_undo_announce(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
ap_id: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alice() -> User {
|
||||||
|
User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("alice").unwrap(),
|
||||||
|
Email::new("alice@ex.com").unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_thought(author_id: UserId) -> Thought {
|
||||||
|
Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
author_id,
|
||||||
|
Content::new_local("hello").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||||
|
FederationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
users: Arc::new(store.clone()),
|
||||||
|
ap: spy,
|
||||||
|
base_url: "https://example.com".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn thought_created_broadcasts_create() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone());
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(spy.created.lock().unwrap().len(), 1);
|
||||||
|
assert_eq!(spy.created.lock().unwrap()[0], thought.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_thought_created_does_not_broadcast() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
// Remote thought: local = false, ap_id = Some(...)
|
||||||
|
let mut thought = local_thought(alice.id.clone());
|
||||||
|
thought.local = false;
|
||||||
|
thought.ap_id = Some("https://remote.example/notes/1".into());
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.created.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let tid = ThoughtId::new();
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtDeleted {
|
||||||
|
thought_id: tid.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let deleted = spy.deleted.lock().unwrap();
|
||||||
|
assert_eq!(deleted.len(), 1);
|
||||||
|
assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn thought_updated_broadcasts_update() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone());
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(spy.updated.lock().unwrap().len(), 1);
|
||||||
|
assert_eq!(spy.updated.lock().unwrap()[0], thought.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_of_local_thought_announces_constructed_url() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone()); // ap_id = None
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::BoostAdded {
|
||||||
|
boost_id: BoostId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let announced = spy.announced.lock().unwrap();
|
||||||
|
assert_eq!(announced.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
announced[0],
|
||||||
|
format!("https://example.com/thoughts/{}", thought.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_of_remote_thought_announces_remote_ap_id() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let mut thought = local_thought(alice.id.clone());
|
||||||
|
thought.local = false;
|
||||||
|
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::BoostAdded {
|
||||||
|
boost_id: BoostId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let announced = spy.announced.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
announced[0],
|
||||||
|
"https://mastodon.social/users/bob/statuses/123"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_thought_created_does_not_broadcast() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("private").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Direct,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.created.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn followers_only_thought_does_not_broadcast_publicly() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("for followers").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Followers,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.created.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unrelated_events_are_noop() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
let svc = svc(&store, spy.clone());
|
||||||
|
|
||||||
|
svc.process(&DomainEvent::UserBlocked {
|
||||||
|
blocker_id: UserId::new(),
|
||||||
|
blocked_id: UserId::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.created.lock().unwrap().is_empty());
|
||||||
|
assert!(spy.deleted.lock().unwrap().is_empty());
|
||||||
|
assert!(spy.updated.lock().unwrap().is_empty());
|
||||||
|
assert!(spy.announced.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn thought_created_does_not_broadcast_if_user_missing() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone());
|
||||||
|
// Don't push alice into users — simulates user deleted before handler runs
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.created.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_removed_sends_undo_announce_for_local_thought() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::BoostRemoved {
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||||
|
assert_eq!(undo_announced.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
undo_announced[0],
|
||||||
|
format!("https://example.com/thoughts/{}", thought.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_removed_sends_undo_announce_for_remote_thought() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let mut thought = local_thought(alice.id.clone());
|
||||||
|
thought.local = false;
|
||||||
|
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into());
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::BoostRemoved {
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||||
|
assert_eq!(undo_announced.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
undo_announced[0],
|
||||||
|
"https://mastodon.social/users/bob/statuses/456"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_removed_does_not_broadcast_if_thought_missing() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::BoostRemoved {
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: ThoughtId::new(), // doesn't exist in store
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(spy.undo_announced.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn thought_updated_does_not_broadcast_if_user_missing() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = local_thought(alice.id.clone());
|
||||||
|
// Don't push alice into users
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
|
let spy = Arc::new(SpyPort::default());
|
||||||
|
svc(&store, spy.clone())
|
||||||
|
.process(&DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(spy.updated.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/application/src/services/mod.rs
Normal file
5
crates/application/src/services/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod federation_event;
|
||||||
|
pub mod notification_event;
|
||||||
|
|
||||||
|
pub use federation_event::FederationEventService;
|
||||||
|
pub use notification_event::NotificationEventService;
|
||||||
316
crates/application/src/services/notification_event.rs
Normal file
316
crates/application/src/services/notification_event.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::notification::{Notification, NotificationType},
|
||||||
|
ports::{NotificationRepository, ThoughtRepository},
|
||||||
|
value_objects::{NotificationId, UserId},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct NotificationEventService {
|
||||||
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
|
pub notifications: Arc<dyn NotificationRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_self_action(thought_author: &UserId, actor: &UserId) -> bool {
|
||||||
|
thought_author == actor
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationEventService {
|
||||||
|
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
match event {
|
||||||
|
DomainEvent::LikeAdded {
|
||||||
|
like_id: _,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
if is_self_action(&thought.user_id, user_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.notifications
|
||||||
|
.save(&Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: thought.user_id,
|
||||||
|
notification_type: NotificationType::Like,
|
||||||
|
from_user_id: Some(user_id.clone()),
|
||||||
|
thought_id: Some(thought_id.clone()),
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
DomainEvent::BoostAdded {
|
||||||
|
boost_id: _,
|
||||||
|
user_id,
|
||||||
|
thought_id,
|
||||||
|
} => {
|
||||||
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
if is_self_action(&thought.user_id, user_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.notifications
|
||||||
|
.save(&Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: thought.user_id,
|
||||||
|
notification_type: NotificationType::Boost,
|
||||||
|
from_user_id: Some(user_id.clone()),
|
||||||
|
thought_id: Some(thought_id.clone()),
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
DomainEvent::FollowAccepted {
|
||||||
|
follower_id,
|
||||||
|
following_id,
|
||||||
|
} => {
|
||||||
|
self.notifications
|
||||||
|
.save(&Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: following_id.clone(),
|
||||||
|
notification_type: NotificationType::Follow,
|
||||||
|
from_user_id: Some(follower_id.clone()),
|
||||||
|
thought_id: None,
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
DomainEvent::ThoughtCreated {
|
||||||
|
thought_id,
|
||||||
|
user_id,
|
||||||
|
in_reply_to_id,
|
||||||
|
} => {
|
||||||
|
let reply_to_id = match in_reply_to_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let original = match self.thoughts.find_by_id(reply_to_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
if is_self_action(&original.user_id, user_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.notifications
|
||||||
|
.save(&Notification {
|
||||||
|
id: NotificationId::new(),
|
||||||
|
user_id: original.user_id,
|
||||||
|
notification_type: NotificationType::Reply,
|
||||||
|
from_user_id: Some(user_id.clone()),
|
||||||
|
thought_id: Some(thought_id.clone()),
|
||||||
|
read: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
models::{
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn alice() -> User {
|
||||||
|
User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("alice").unwrap(),
|
||||||
|
Email::new("alice@ex.com").unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn like_creates_notification_for_thought_author() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let bob_id = UserId::new();
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("hello").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::LikeAdded {
|
||||||
|
like_id: LikeId::new(),
|
||||||
|
user_id: bob_id,
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let notifs = store.notifications.lock().unwrap();
|
||||||
|
assert_eq!(notifs.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
notifs[0].notification_type,
|
||||||
|
NotificationType::Like
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_like_creates_no_notification() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("hello").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::LikeAdded {
|
||||||
|
like_id: LikeId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.notifications.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_accepted_creates_notification() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let bob_id = UserId::new();
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::FollowAccepted {
|
||||||
|
follower_id: bob_id,
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let notifs = store.notifications.lock().unwrap();
|
||||||
|
assert_eq!(notifs.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
notifs[0].notification_type,
|
||||||
|
NotificationType::Follow
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reply_creates_notification_for_original_author() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let bob_id = UserId::new();
|
||||||
|
let original = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("original").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: ThoughtId::new(),
|
||||||
|
user_id: bob_id,
|
||||||
|
in_reply_to_id: Some(original.id.clone()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let notifs = store.notifications.lock().unwrap();
|
||||||
|
assert_eq!(notifs.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
notifs[0].notification_type,
|
||||||
|
NotificationType::Reply
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_reply_creates_no_notification() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let original = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("original").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: ThoughtId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
in_reply_to_id: Some(original.id.clone()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.notifications.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_boost_creates_no_notification() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = alice();
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("hello").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
let svc = NotificationEventService {
|
||||||
|
thoughts: Arc::new(store.clone()),
|
||||||
|
notifications: Arc::new(store.clone()),
|
||||||
|
};
|
||||||
|
svc.process(&DomainEvent::BoostAdded {
|
||||||
|
boost_id: BoostId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.notifications.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/application/src/use_cases/api_keys.rs
Normal file
101
crates/application/src/use_cases/api_keys.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::api_key::ApiKey,
|
||||||
|
ports::ApiKeyRepository,
|
||||||
|
value_objects::{ApiKeyId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_api_keys(
|
||||||
|
keys: &dyn ApiKeyRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<ApiKey>, DomainError> {
|
||||||
|
keys.list_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(
|
||||||
|
keys: &dyn ApiKeyRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(ApiKey, String), DomainError> {
|
||||||
|
let raw_key = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||||
|
let key_hash = sha256_hex(&raw_key);
|
||||||
|
let key = ApiKey {
|
||||||
|
id: ApiKeyId::new(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
key_hash,
|
||||||
|
name,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
keys.save(&key).await?;
|
||||||
|
Ok((key, raw_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_api_key(
|
||||||
|
keys: &dyn ApiKeyRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
key_id: &ApiKeyId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
keys.delete(key_id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(s: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let hash = Sha256::digest(s.as_bytes());
|
||||||
|
hex::encode(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{testing::TestStore, value_objects::UserId};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_key_saves_hashed_not_raw() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
let (key, raw) = create_api_key(&store, &uid, "my-key".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(key.key_hash, raw, "stored hash must differ from raw key");
|
||||||
|
assert!(!key.key_hash.is_empty());
|
||||||
|
assert_eq!(key.name, "my-key");
|
||||||
|
assert_eq!(key.user_id, uid);
|
||||||
|
assert_eq!(store.api_keys.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn raw_key_verifies_against_stored_hash() {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
let (key, raw) = create_api_key(&store, &uid, "test".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let expected_hash = hex::encode(Sha256::digest(raw.as_bytes()));
|
||||||
|
assert_eq!(key.key_hash, expected_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_key_removes_it() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
let (key, _) = create_api_key(&store, &uid, "k".to_string()).await.unwrap();
|
||||||
|
delete_api_key(&store, &uid, &key.id).await.unwrap();
|
||||||
|
assert!(store.api_keys.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_keys_returns_only_own_keys() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = UserId::new();
|
||||||
|
let bob = UserId::new();
|
||||||
|
create_api_key(&store, &alice, "a".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
create_api_key(&store, &bob, "b".to_string()).await.unwrap();
|
||||||
|
let alice_keys = list_api_keys(&store, &alice).await.unwrap();
|
||||||
|
assert_eq!(alice_keys.len(), 1);
|
||||||
|
assert_eq!(alice_keys[0].user_id, alice);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
crates/application/src/use_cases/auth.rs
Normal file
238
crates/application/src/use_cases/auth.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::user::User,
|
||||||
|
ports::{AuthService, EventPublisher, PasswordHasher, UserRepository},
|
||||||
|
value_objects::{Email, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct RegisterInput {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RegisterOutput {
|
||||||
|
pub user: User,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
hasher: &dyn PasswordHasher,
|
||||||
|
auth: &dyn AuthService,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
input: RegisterInput,
|
||||||
|
) -> Result<RegisterOutput, DomainError> {
|
||||||
|
let username = Username::new(input.username)?;
|
||||||
|
let email = Email::new(input.email)?;
|
||||||
|
if users.find_by_username(&username).await?.is_some() {
|
||||||
|
return Err(DomainError::Conflict("username taken".into()));
|
||||||
|
}
|
||||||
|
if users.find_by_email(&email).await?.is_some() {
|
||||||
|
return Err(DomainError::Conflict("email taken".into()));
|
||||||
|
}
|
||||||
|
let hash = hasher.hash(&input.password).await?;
|
||||||
|
let user = User::new_local(UserId::new(), username, email, hash);
|
||||||
|
users.save(&user).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::UserRegistered {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let token = auth.generate_token(&user.id)?;
|
||||||
|
Ok(RegisterOutput {
|
||||||
|
user,
|
||||||
|
token: token.token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginInput {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoginOutput {
|
||||||
|
pub user: User,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
hasher: &dyn PasswordHasher,
|
||||||
|
auth: &dyn AuthService,
|
||||||
|
input: LoginInput,
|
||||||
|
) -> Result<LoginOutput, DomainError> {
|
||||||
|
let email = Email::new(input.email)?;
|
||||||
|
let user = users
|
||||||
|
.find_by_email(&email)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::Unauthorized)?;
|
||||||
|
if !hasher.verify(&input.password, &user.password_hash).await? {
|
||||||
|
return Err(DomainError::Unauthorized);
|
||||||
|
}
|
||||||
|
let token = auth.generate_token(&user.id)?;
|
||||||
|
Ok(LoginOutput {
|
||||||
|
user,
|
||||||
|
token: token.token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FakeHasher;
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for FakeHasher {
|
||||||
|
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
Ok(PasswordHash(plain.to_string()))
|
||||||
|
}
|
||||||
|
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
Ok(plain == hash.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeAuth;
|
||||||
|
impl AuthService for FakeAuth {
|
||||||
|
fn generate_token(&self, uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
Ok(GeneratedToken {
|
||||||
|
token: uid.to_string(),
|
||||||
|
user_id: uid.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
Ok(UserId::from_uuid(
|
||||||
|
uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input() -> RegisterInput {
|
||||||
|
RegisterInput {
|
||||||
|
username: "alice".into(),
|
||||||
|
email: "alice@ex.com".into(),
|
||||||
|
password: "pw".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_creates_user() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.user.username.as_str(), "alice");
|
||||||
|
assert!(!out.token.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_duplicate_username() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Conflict(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_succeeds_with_correct_password() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let out = login(
|
||||||
|
&store,
|
||||||
|
&FakeHasher,
|
||||||
|
&FakeAuth,
|
||||||
|
LoginInput {
|
||||||
|
email: "alice@ex.com".into(),
|
||||||
|
password: "pw".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.token.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_wrong_password() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = login(
|
||||||
|
&store,
|
||||||
|
&FakeHasher,
|
||||||
|
&FakeAuth,
|
||||||
|
LoginInput {
|
||||||
|
email: "alice@ex.com".into(),
|
||||||
|
password: "wrong".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_publishes_user_registered_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &store, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let events = store.events.lock().unwrap();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(events[0], DomainEvent::UserRegistered { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_for_nonexistent_user() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let err = login(
|
||||||
|
&store,
|
||||||
|
&FakeHasher,
|
||||||
|
&FakeAuth,
|
||||||
|
LoginInput {
|
||||||
|
email: "ghost@ex.com".into(),
|
||||||
|
password: "pass".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_duplicate_email() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = register(
|
||||||
|
&store,
|
||||||
|
&FakeHasher,
|
||||||
|
&FakeAuth,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
RegisterInput {
|
||||||
|
username: "alice2".into(),
|
||||||
|
email: "alice@ex.com".into(),
|
||||||
|
password: "pass2".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Conflict(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/application/src/use_cases/feed.rs
Normal file
82
crates/application/src/use_cases/feed.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::{FeedRepository, FollowRepository, TagRepository, UserRepository},
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_home_feed(
|
||||||
|
feed: &dyn FeedRepository,
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||||
|
following_ids.push(user_id.clone()); // include own thoughts in home feed
|
||||||
|
feed.home_feed(&following_ids, &page, Some(user_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_public_feed(
|
||||||
|
feed: &dyn FeedRepository,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.public_feed(&page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_feed(
|
||||||
|
feed: &dyn FeedRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.user_feed(user_id, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
follows.list_followers(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
follows.list_following(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_tag(
|
||||||
|
feed: &dyn FeedRepository,
|
||||||
|
tag_name: &str,
|
||||||
|
page: PageParams,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.tag_feed(tag_name, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
feed: &dyn FeedRepository,
|
||||||
|
query: &str,
|
||||||
|
page: PageParams,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.search(query, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
users.list_with_stats().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_popular_tags(
|
||||||
|
tags: &dyn TagRepository,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<(String, i64)>, DomainError> {
|
||||||
|
tags.popular_tags(limit).await
|
||||||
|
}
|
||||||
8
crates/application/src/use_cases/mod.rs
Normal file
8
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod api_keys;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod search;
|
||||||
|
pub mod social;
|
||||||
|
pub mod thoughts;
|
||||||
30
crates/application/src/use_cases/notifications.rs
Normal file
30
crates/application/src/use_cases/notifications.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::feed::{PageParams, Paginated},
|
||||||
|
models::notification::Notification,
|
||||||
|
ports::NotificationRepository,
|
||||||
|
value_objects::{NotificationId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_notifications(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<Notification>, DomainError> {
|
||||||
|
repo.list_for_user(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
id: &NotificationId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
repo.mark_read(id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_all_notifications_read(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
repo.mark_all_read(user_id).await
|
||||||
|
}
|
||||||
138
crates/application/src/use_cases/profile.rs
Normal file
138
crates/application/src/use_cases/profile.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{top_friend::TopFriend, user::User},
|
||||||
|
ports::{TopFriendRepository, UserRepository},
|
||||||
|
value_objects::{UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<User, DomainError> {
|
||||||
|
users
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<User, DomainError> {
|
||||||
|
let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
||||||
|
users
|
||||||
|
.find_by_username(&username)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_profile(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
users
|
||||||
|
.update_profile(
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
avatar_url,
|
||||||
|
header_url,
|
||||||
|
custom_css,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_top_friends(
|
||||||
|
top_friends: &dyn TopFriendRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||||
|
top_friends.list_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_top_friends(
|
||||||
|
top_friends: &dyn TopFriendRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
friend_ids: Vec<UserId>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if friend_ids.len() > 8 {
|
||||||
|
return Err(DomainError::InvalidInput("top friends: max 8".into()));
|
||||||
|
}
|
||||||
|
let friends: Vec<(UserId, i16)> = friend_ids
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, id)| (id, (i + 1) as i16))
|
||||||
|
.collect();
|
||||||
|
top_friends.set_top_friends(user_id, friends).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::user::User,
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn make_user() -> User {
|
||||||
|
User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("alice").unwrap(),
|
||||||
|
Email::new("alice@ex.com").unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_top_friends_rejects_more_than_eight() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
let friends: Vec<UserId> = (0..9).map(|_| UserId::new()).collect();
|
||||||
|
let err = set_top_friends(&store, &uid, friends).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_top_friends_assigns_sequential_positions() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
let f1 = UserId::new();
|
||||||
|
let f2 = UserId::new();
|
||||||
|
let f3 = UserId::new();
|
||||||
|
set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let tf = store.top_friends.lock().unwrap();
|
||||||
|
assert_eq!(tf.len(), 3);
|
||||||
|
let pos_f1 = tf
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.friend_id == f1)
|
||||||
|
.map(|t| t.position)
|
||||||
|
.unwrap();
|
||||||
|
let pos_f2 = tf
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.friend_id == f2)
|
||||||
|
.map(|t| t.position)
|
||||||
|
.unwrap();
|
||||||
|
assert!(pos_f1 < pos_f2, "f1 should come before f2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_user_by_username_returns_not_found_for_missing_user() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let err = get_user_by_username(&store, "nobody").await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_user_by_username_returns_correct_user() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let user = make_user();
|
||||||
|
store.users.lock().unwrap().push(user.clone());
|
||||||
|
let found = get_user_by_username(&store, "alice").await.unwrap();
|
||||||
|
assert_eq!(found.id, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
crates/application/src/use_cases/search.rs
Normal file
26
crates/application/src/use_cases/search.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, PageParams, Paginated},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::SearchPort,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn search_thoughts(
|
||||||
|
search: &dyn SearchPort,
|
||||||
|
query: &str,
|
||||||
|
page: PageParams,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
search.search_thoughts(query, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_users(
|
||||||
|
search: &dyn SearchPort,
|
||||||
|
query: &str,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
search.search_users(query, &page).await
|
||||||
|
}
|
||||||
339
crates/application/src/use_cases/social.rs
Normal file
339
crates/application/src/use_cases/social.rs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::social::{Block, Boost, Follow, FollowState, Like},
|
||||||
|
ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository},
|
||||||
|
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn like_thought(
|
||||||
|
likes: &dyn LikeRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
user_id: &UserId,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let like = Like {
|
||||||
|
id: LikeId::new(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
likes.save(&like).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::LikeAdded {
|
||||||
|
like_id: like.id,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unlike_thought(
|
||||||
|
likes: &dyn LikeRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
user_id: &UserId,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
likes.delete(user_id, thought_id).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::LikeRemoved {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn boost_thought(
|
||||||
|
boosts: &dyn BoostRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
user_id: &UserId,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let boost = Boost {
|
||||||
|
id: BoostId::new(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
boosts.save(&boost).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::BoostAdded {
|
||||||
|
boost_id: boost.id,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unboost_thought(
|
||||||
|
boosts: &dyn BoostRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
user_id: &UserId,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
boosts.delete(user_id, thought_id).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::BoostRemoved {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
thought_id: thought_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_user(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if follower_id == following_id {
|
||||||
|
return Err(DomainError::InvalidInput("cannot follow yourself".into()));
|
||||||
|
}
|
||||||
|
let follow = Follow {
|
||||||
|
follower_id: follower_id.clone(),
|
||||||
|
following_id: following_id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
follows.save(&follow).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::FollowAccepted {
|
||||||
|
follower_id: follower_id.clone(),
|
||||||
|
following_id: following_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_user(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
follows.delete(follower_id, following_id).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::Unfollowed {
|
||||||
|
follower_id: follower_id.clone(),
|
||||||
|
following_id: following_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_follow(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
follows
|
||||||
|
.update_state(follower_id, following_id, &FollowState::Accepted)
|
||||||
|
.await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::FollowAccepted {
|
||||||
|
follower_id: follower_id.clone(),
|
||||||
|
following_id: following_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject_follow(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
follower_id: &UserId,
|
||||||
|
following_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
follows
|
||||||
|
.update_state(follower_id, following_id, &FollowState::Rejected)
|
||||||
|
.await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::FollowRejected {
|
||||||
|
follower_id: follower_id.clone(),
|
||||||
|
following_id: following_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn block_user(
|
||||||
|
blocks: &dyn BlockRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
blocker_id: &UserId,
|
||||||
|
blocked_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if blocker_id == blocked_id {
|
||||||
|
return Err(DomainError::InvalidInput("cannot block yourself".into()));
|
||||||
|
}
|
||||||
|
let block = Block {
|
||||||
|
blocker_id: blocker_id.clone(),
|
||||||
|
blocked_id: blocked_id.clone(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
blocks.save(&block).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::UserBlocked {
|
||||||
|
blocker_id: blocker_id.clone(),
|
||||||
|
blocked_id: blocked_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unblock_user(
|
||||||
|
blocks: &dyn BlockRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
blocker_id: &UserId,
|
||||||
|
blocked_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
blocks.delete(blocker_id, blocked_id).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::UserUnblocked {
|
||||||
|
blocker_id: blocker_id.clone(),
|
||||||
|
blocked_id: blocked_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
models::{
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn user(name: &str) -> User {
|
||||||
|
User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new(name).unwrap(),
|
||||||
|
Email::new(format!("{name}@ex.com")).unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn like_and_unlike() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let tid = ThoughtId::new();
|
||||||
|
store.thoughts.lock().unwrap().push(Thought::new_local(
|
||||||
|
tid.clone(),
|
||||||
|
alice.id.clone(),
|
||||||
|
Content::new_local("hi").unwrap(),
|
||||||
|
None,
|
||||||
|
Visibility::Public,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||||
|
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
||||||
|
unlike_thought(&store, &store, &alice.id, &tid)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.likes.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_and_unfollow() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let bob = user("bob");
|
||||||
|
follow_user(&store, &store, &alice.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.follows.lock().unwrap().len(), 1);
|
||||||
|
unfollow_user(&store, &store, &alice.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.follows.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cannot_follow_self() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let err = follow_user(&store, &store, &alice.id, &alice.id)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unblock_user_publishes_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let bob = user("bob");
|
||||||
|
block_user(&store, &store, &alice.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store.events.lock().unwrap().clear();
|
||||||
|
unblock_user(&store, &store, &alice.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let events = store.events.lock().unwrap();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(events[0], DomainEvent::UserUnblocked { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn block_user_saves_block_and_publishes_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let bob = user("bob");
|
||||||
|
block_user(&store, &store, &alice.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.blocks.lock().unwrap().len(), 1);
|
||||||
|
let events = store.events.lock().unwrap();
|
||||||
|
assert!(events.iter().any(
|
||||||
|
|e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cannot_block_self() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let err = block_user(&store, &store, &alice.id, &alice.id)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_and_unboost() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let tid = ThoughtId::new();
|
||||||
|
boost_thought(&store, &store, &alice.id, &tid)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.boosts.lock().unwrap().len(), 1);
|
||||||
|
unboost_thought(&store, &store, &alice.id, &tid)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.boosts.lock().unwrap().is_empty());
|
||||||
|
let events = store.events.lock().unwrap();
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, DomainEvent::BoostAdded { .. })));
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
312
crates/application/src/use_cases/thoughts.rs
Normal file
312
crates/application/src/use_cases/thoughts.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::thought::{Thought, Visibility},
|
||||||
|
ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository},
|
||||||
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn extract_hashtags(content: &str) -> Vec<String> {
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
let mut chars = content.char_indices().peekable();
|
||||||
|
while let Some((_, c)) = chars.next() {
|
||||||
|
if c == '#'
|
||||||
|
&& chars
|
||||||
|
.peek()
|
||||||
|
.map(|(_, nc)| nc.is_alphanumeric())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let tag: String = chars
|
||||||
|
.by_ref()
|
||||||
|
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
||||||
|
.map(|(_, nc)| nc)
|
||||||
|
.collect();
|
||||||
|
if !tag.is_empty() {
|
||||||
|
tags.push(tag.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags.dedup();
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
if thought.user_id != *user_id {
|
||||||
|
return Err(DomainError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreateThoughtInput {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub content: String,
|
||||||
|
pub in_reply_to_id: Option<ThoughtId>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
}
|
||||||
|
pub struct CreateThoughtOutput {
|
||||||
|
pub thought: Thought,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
_users: &dyn UserRepository,
|
||||||
|
tags: &dyn TagRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
input: CreateThoughtInput,
|
||||||
|
) -> Result<CreateThoughtOutput, DomainError> {
|
||||||
|
let content = Content::new_local(input.content)?;
|
||||||
|
let visibility = input
|
||||||
|
.visibility
|
||||||
|
.as_deref()
|
||||||
|
.map(Visibility::from_db_str)
|
||||||
|
.unwrap_or(Visibility::Public);
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(),
|
||||||
|
input.user_id,
|
||||||
|
content.clone(),
|
||||||
|
input.in_reply_to_id.clone(),
|
||||||
|
visibility,
|
||||||
|
input.content_warning,
|
||||||
|
input.sensitive,
|
||||||
|
);
|
||||||
|
thoughts.save(&thought).await?;
|
||||||
|
|
||||||
|
// Extract and attach hashtags from content.
|
||||||
|
for tag_name in extract_hashtags(content.as_str()) {
|
||||||
|
if let Ok(tag) = tags.find_or_create(&tag_name).await {
|
||||||
|
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: thought.user_id.clone(),
|
||||||
|
in_reply_to_id: input.in_reply_to_id,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(CreateThoughtOutput { thought })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
id: &ThoughtId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let thought = thoughts
|
||||||
|
.find_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
require_owner(&thought, user_id)?;
|
||||||
|
thoughts.delete(id, user_id).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::ThoughtDeleted {
|
||||||
|
thought_id: id.clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
id: &ThoughtId,
|
||||||
|
user_id: &UserId,
|
||||||
|
new_content: String,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let thought = thoughts
|
||||||
|
.find_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
require_owner(&thought, user_id)?;
|
||||||
|
let content = Content::new_local(new_content)?;
|
||||||
|
thoughts.update_content(id, &content).await?;
|
||||||
|
events
|
||||||
|
.publish(&DomainEvent::ThoughtUpdated {
|
||||||
|
thought_id: id.clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
id: &ThoughtId,
|
||||||
|
) -> Result<Thought, DomainError> {
|
||||||
|
thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thread(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
id: &ThoughtId,
|
||||||
|
) -> Result<Vec<Thought>, DomainError> {
|
||||||
|
thoughts.get_thread(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
models::user::User,
|
||||||
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn user() -> User {
|
||||||
|
User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("alice").unwrap(),
|
||||||
|
Email::new("alice@ex.com").unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input(uid: UserId) -> CreateThoughtInput {
|
||||||
|
CreateThoughtInput {
|
||||||
|
user_id: uid,
|
||||||
|
content: "hello".into(),
|
||||||
|
in_reply_to_id: None,
|
||||||
|
visibility: None,
|
||||||
|
content_warning: None,
|
||||||
|
sensitive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_thought_saves_and_emits_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let u = user();
|
||||||
|
store.users.lock().unwrap().push(u.clone());
|
||||||
|
let out = create_thought(&store, &store, &store, &store, input(u.id.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.thought.content.as_str(), "hello");
|
||||||
|
assert_eq!(store.events.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_own_thought_succeeds() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let u = user();
|
||||||
|
store.users.lock().unwrap().push(u.clone());
|
||||||
|
let out = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
input(u.id.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_other_thought_returns_not_found() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user();
|
||||||
|
let bob = User::new_local(
|
||||||
|
UserId::new(),
|
||||||
|
Username::new("bob").unwrap(),
|
||||||
|
Email::new("bob@ex.com").unwrap(),
|
||||||
|
PasswordHash("h".into()),
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.users
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.extend([alice.clone(), bob.clone()]);
|
||||||
|
let out = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
input(alice.id.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_thought_changes_content_and_emits_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user();
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
let out = create_thought(&store, &store, &store, &store, input(alice.id.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let tid = out.thought.id.clone();
|
||||||
|
|
||||||
|
edit_thought(&store, &store, &tid, &alice.id, "updated".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let saved = store
|
||||||
|
.thoughts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == tid)
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(saved.content.as_str(), "updated");
|
||||||
|
|
||||||
|
let events = store.events.lock().unwrap();
|
||||||
|
assert!(events.iter().any(
|
||||||
|
|e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_reply_sets_in_reply_to_id() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user();
|
||||||
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
|
let original = create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
input(alice.id.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.thought;
|
||||||
|
|
||||||
|
create_thought(
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&store,
|
||||||
|
&NoOpEventPublisher,
|
||||||
|
CreateThoughtInput {
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
content: "reply".into(),
|
||||||
|
in_reply_to_id: Some(original.id.clone()),
|
||||||
|
visibility: None,
|
||||||
|
content_warning: None,
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let thoughts = store.thoughts.lock().unwrap();
|
||||||
|
let reply = thoughts
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.content.as_str() == "reply")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reply.in_reply_to_id, Some(original.id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/bootstrap/Cargo.toml
Normal file
31
crates/bootstrap/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "bootstrap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "thoughts"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
presentation = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
postgres = { workspace = true }
|
||||||
|
postgres-search = { workspace = true }
|
||||||
|
postgres-federation = { workspace = true }
|
||||||
|
activitypub = { workspace = true }
|
||||||
|
activitypub-base = { workspace = true }
|
||||||
|
nats = { workspace = true }
|
||||||
|
event-transport = { workspace = true }
|
||||||
|
auth = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
async-nats = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
tower_governor = "0.8"
|
||||||
|
http = "1"
|
||||||
41
crates/bootstrap/src/config.rs
Normal file
41
crates/bootstrap/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/// All configuration read from environment variables at startup.
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub nats_url: Option<String>,
|
||||||
|
pub port: u16,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
/// true when RUST_ENV != "production" — enables AP debug mode
|
||||||
|
pub debug: bool,
|
||||||
|
pub host: String,
|
||||||
|
pub cors_origins: String,
|
||||||
|
pub rate_limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
Self {
|
||||||
|
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
|
||||||
|
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"),
|
||||||
|
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||||
|
nats_url: std::env::var("NATS_URL").ok(),
|
||||||
|
port: std::env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(3000),
|
||||||
|
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(true),
|
||||||
|
debug: std::env::var("RUST_ENV")
|
||||||
|
.map(|v| v != "production")
|
||||||
|
.unwrap_or(true),
|
||||||
|
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
|
||||||
|
cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()),
|
||||||
|
rate_limit: std::env::var("RATE_LIMIT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
crates/bootstrap/src/factory.rs
Normal file
116
crates/bootstrap/src/factory.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use activitypub::ThoughtsObjectHandler;
|
||||||
|
use activitypub_base::service::ActivityPubService;
|
||||||
|
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||||
|
use event_transport::EventPublisherAdapter;
|
||||||
|
use nats::NatsTransport;
|
||||||
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
|
use presentation::state::AppState;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
/// Everything the binary needs to start serving.
|
||||||
|
pub struct Infrastructure {
|
||||||
|
pub state: AppState,
|
||||||
|
pub ap_service: Arc<ActivityPubService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoOpEventPublisher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for NoOpEventPublisher {
|
||||||
|
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build(cfg: &Config) -> Infrastructure {
|
||||||
|
// 1. Database connection + migrations
|
||||||
|
let pool = PgPool::connect(&cfg.database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
sqlx::migrate!("../adapters/postgres/migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
tracing::info!("Database connected and migrations applied");
|
||||||
|
|
||||||
|
// 2. Event publisher — real NATS or no-op fallback
|
||||||
|
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
|
||||||
|
Some(url) => match async_nats::connect(url).await {
|
||||||
|
Ok(client) => {
|
||||||
|
tracing::info!("Connected to NATS at {url}");
|
||||||
|
if let Err(e) = nats::ensure_stream(&client).await {
|
||||||
|
tracing::warn!("JetStream stream setup failed: {e} — events may be lost");
|
||||||
|
}
|
||||||
|
Arc::new(EventPublisherAdapter::new(NatsTransport::new(client)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
||||||
|
Arc::new(NoOpEventPublisher)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||||
|
Arc::new(NoOpEventPublisher)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. ActivityPub federation
|
||||||
|
let ap_service = Arc::new(
|
||||||
|
ActivityPubService::new(
|
||||||
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
|
Arc::new(PostgresApUserRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
)),
|
||||||
|
Arc::new(ThoughtsObjectHandler::new(
|
||||||
|
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
|
&cfg.base_url,
|
||||||
|
)),
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
cfg.allow_registration,
|
||||||
|
"thoughts".to_string(),
|
||||||
|
cfg.debug,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to build ActivityPubService"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Application state
|
||||||
|
let state = AppState {
|
||||||
|
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
||||||
|
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
|
||||||
|
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
|
||||||
|
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
|
||||||
|
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
||||||
|
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
||||||
|
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||||
|
api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
||||||
|
top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
)),
|
||||||
|
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
)),
|
||||||
|
remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
)),
|
||||||
|
feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())),
|
||||||
|
search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())),
|
||||||
|
auth: Arc::new(auth::JwtAuthService::new(
|
||||||
|
cfg.jwt_secret.clone(),
|
||||||
|
86400 * 30,
|
||||||
|
)),
|
||||||
|
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||||
|
events: event_publisher,
|
||||||
|
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||||
|
};
|
||||||
|
|
||||||
|
Infrastructure { state, ap_service }
|
||||||
|
}
|
||||||
115
crates/bootstrap/src/main.rs
Normal file
115
crates/bootstrap/src/main.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
mod config;
|
||||||
|
mod factory;
|
||||||
|
|
||||||
|
use activitypub_base::{
|
||||||
|
actor_handler::actor_handler,
|
||||||
|
followers_handler::{followers_handler, following_handler},
|
||||||
|
inbox::inbox_handler,
|
||||||
|
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||||
|
outbox::outbox_handler,
|
||||||
|
webfinger::webfinger_handler,
|
||||||
|
};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cfg = config::Config::from_env();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let infra = factory::build(&cfg).await;
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
let cors = if cfg.cors_origins.trim() == "*" {
|
||||||
|
CorsLayer::permissive()
|
||||||
|
} else {
|
||||||
|
let origins: Vec<http::HeaderValue> = cfg
|
||||||
|
.cors_origins
|
||||||
|
.split(',')
|
||||||
|
.map(|o| o.trim())
|
||||||
|
.filter_map(|o| o.parse().ok())
|
||||||
|
.collect();
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::list(origins))
|
||||||
|
.allow_methods(tower_http::cors::Any)
|
||||||
|
.allow_headers(tower_http::cors::Any)
|
||||||
|
};
|
||||||
|
|
||||||
|
let ap_router = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
axum::routing::get(webfinger_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/.well-known/nodeinfo",
|
||||||
|
axum::routing::get(nodeinfo_well_known_handler),
|
||||||
|
)
|
||||||
|
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
||||||
|
.route("/users/{username}", axum::routing::get(actor_handler))
|
||||||
|
.route(
|
||||||
|
"/users/{username}/inbox",
|
||||||
|
axum::routing::post(inbox_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{username}/outbox",
|
||||||
|
axum::routing::get(outbox_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{username}/followers",
|
||||||
|
axum::routing::get(followers_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{username}/following",
|
||||||
|
axum::routing::get(following_handler),
|
||||||
|
)
|
||||||
|
.layer(infra.ap_service.federation_config().middleware());
|
||||||
|
|
||||||
|
let base = presentation::routes::router()
|
||||||
|
.merge(ap_router)
|
||||||
|
.with_state(infra.state)
|
||||||
|
.layer(cors);
|
||||||
|
|
||||||
|
let addr = format!("{}:{}", cfg.host, cfg.port);
|
||||||
|
tracing::info!("Listening on {addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
|
||||||
|
if let Some(rate_limit) = cfg.rate_limit {
|
||||||
|
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; // crate: tower_governor
|
||||||
|
|
||||||
|
// per_millisecond sets the token replenishment interval.
|
||||||
|
// rate_limit = max requests/minute => replenish every (60000 / rate_limit) ms.
|
||||||
|
let ms = (60_000u64).saturating_div(rate_limit as u64).max(1);
|
||||||
|
let governor_conf = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.per_millisecond(ms)
|
||||||
|
.burst_size(rate_limit)
|
||||||
|
.use_headers()
|
||||||
|
.finish()
|
||||||
|
.expect("valid rate limit config"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let limiter = governor_conf.limiter().clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
limiter.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = base.layer(GovernorLayer::new(governor_conf));
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
axum::serve(listener, base).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/domain/Cargo.toml
Normal file
19
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
test-helpers = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
19
crates/domain/src/errors.rs
Normal file
19
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("invalid input: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("external service error: {0}")]
|
||||||
|
ExternalService(String),
|
||||||
|
#[error("internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
76
crates/domain/src/events.rs
Normal file
76
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::value_objects::{BoostId, LikeId, ThoughtId, UserId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DomainEvent {
|
||||||
|
ThoughtCreated {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
user_id: UserId,
|
||||||
|
in_reply_to_id: Option<ThoughtId>,
|
||||||
|
},
|
||||||
|
ThoughtDeleted {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
user_id: UserId,
|
||||||
|
},
|
||||||
|
ThoughtUpdated {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
user_id: UserId,
|
||||||
|
},
|
||||||
|
LikeAdded {
|
||||||
|
like_id: LikeId,
|
||||||
|
user_id: UserId,
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
},
|
||||||
|
LikeRemoved {
|
||||||
|
user_id: UserId,
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
},
|
||||||
|
BoostAdded {
|
||||||
|
boost_id: BoostId,
|
||||||
|
user_id: UserId,
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
},
|
||||||
|
BoostRemoved {
|
||||||
|
user_id: UserId,
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
},
|
||||||
|
FollowRequested {
|
||||||
|
follower_id: UserId,
|
||||||
|
following_id: UserId,
|
||||||
|
},
|
||||||
|
FollowAccepted {
|
||||||
|
follower_id: UserId,
|
||||||
|
following_id: UserId,
|
||||||
|
},
|
||||||
|
FollowRejected {
|
||||||
|
follower_id: UserId,
|
||||||
|
following_id: UserId,
|
||||||
|
},
|
||||||
|
Unfollowed {
|
||||||
|
follower_id: UserId,
|
||||||
|
following_id: UserId,
|
||||||
|
},
|
||||||
|
UserBlocked {
|
||||||
|
blocker_id: UserId,
|
||||||
|
blocked_id: UserId,
|
||||||
|
},
|
||||||
|
UserUnblocked {
|
||||||
|
blocker_id: UserId,
|
||||||
|
blocked_id: UserId,
|
||||||
|
},
|
||||||
|
UserRegistered {
|
||||||
|
user_id: UserId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventEnvelope {
|
||||||
|
pub event: DomainEvent,
|
||||||
|
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
}
|
||||||
|
impl std::fmt::Debug for EventEnvelope {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("EventEnvelope")
|
||||||
|
.field("event", &self.event)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/domain/src/lib.rs
Normal file
8
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod errors;
|
||||||
|
pub mod events;
|
||||||
|
pub mod models;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "test-helpers"))]
|
||||||
|
pub mod testing;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user