diff --git a/.env b/.env deleted file mode 100644 index 358ba6c..0000000 --- a/.env +++ /dev/null @@ -1,10 +0,0 @@ -POSTGRES_USER=thoughts_user -POSTGRES_PASSWORD=postgres -POSTGRES_DB=thoughts_db - -HOST=0.0.0.0 -PORT=8000 -DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db" -PREFORK=1 -AUTH_SECRET=secret -BASE_URL=http://0.0.0.0 \ No newline at end of file diff --git a/.env.example b/.env.example index a56f5fe..d15d6c4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,28 @@ -POSTGRES_USER=thoughts_user -POSTGRES_PASSWORD=postgres -POSTGRES_DB=thoughts_db \ No newline at end of file +# Database (PostgreSQL required) +DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts + +# Authentication +JWT_SECRET=change-me + +# Public base URL — used for ActivityPub actor URLs and canonical links +BASE_URL=http://localhost:3000 + +# Optional +HOST=0.0.0.0 +PORT=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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 31e00f4..5ed69ba 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,41 +1,19 @@ -name: Build and Deploy Thoughts +name: deploy on: - push: - branches: - - master workflow_dispatch: jobs: - build-and-deploy-local: + deploy: runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Create .env file - run: | - echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env - echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env - echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env - echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env - echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env - - - name: Build Docker Images Manually - run: | - docker build --target runtime -t thoughts-backend:latest ./thoughts-backend - docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend - docker build -t custom-proxy:latest ./nginx - - - name: Deploy with Docker Compose - run: | - docker compose -f compose.prod.yml down - - POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ - POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ - POSTGRES_DB=${{ secrets.POSTGRES_DB }} \ - AUTH_SECRET=${{ secrets.AUTH_SECRET }} \ - docker compose -f compose.prod.yml up -d - - docker image prune -f \ No newline at end of file + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_KEY }} + script: | + docker pull registry.gabrielkaszewski.dev/thoughts:latest + docker pull registry.gabrielkaszewski.dev/thoughts-frontend:latest + docker compose -f /opt/thoughts/docker-compose.yml up -d diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..1f9379e --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..1089cf9 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..478b1bd --- /dev/null +++ b/.githooks/pre-commit @@ -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 diff --git a/.gitignore b/.gitignore index 392d20e..cc50778 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -backend-codebase.txt -frontend-codebase.txt -.env \ No newline at end of file +.env + +/target diff --git a/API Design.md b/API Design.md deleted file mode 100644 index 3527b46..0000000 --- a/API Design.md +++ /dev/null @@ -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 \ 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 \ 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" -} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f9a52ac --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4881 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "activitypub" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "anyhow", + "async-trait", + "chrono", + "domain", + "serde", + "serde_json", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "activitypub-base" +version = "0.1.0" +dependencies = [ + "activitypub_federation", + "anyhow", + "async-trait", + "axum", + "chrono", + "domain", + "enum_delegate", + "futures", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "activitypub_federation" +version = "0.7.0-beta.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20222f29f14358d3baeb0ffdec08f99bd0f56b6b59504f33556d97db720de748" +dependencies = [ + "activitystreams-kinds", + "actix-web", + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "derive_builder", + "dyn-clone", + "either", + "enum_delegate", + "futures", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-signature-normalization", + "http-signature-normalization-reqwest", + "httpdate", + "itertools", + "moka", + "pin-project-lite", + "rand 0.8.6", + "regex", + "reqwest", + "reqwest-middleware", + "rsa", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "url", +] + +[[package]] +name = "activitystreams-kinds" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e97dfe76efd8c0b113cc3580a6b5f4acba47662e3cfbbfcce081c9ac89798990" +dependencies = [ + "serde", + "url", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes", + "bytestring", + "cfg-if", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "api-types" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "utoipa", + "uuid", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "async-trait", + "chrono", + "domain", + "hex", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31811585c7c5bc2f60f8b80d5a6b0f737115611dac47567d7f7d94562ebb180b" +dependencies = [ + "base64", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand 0.10.1", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auth" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "bcrypt", + "chrono", + "domain", + "hex", + "jsonwebtoken", + "rand 0.8.6", + "serde", + "sha2", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "activitypub", + "activitypub-base", + "async-nats", + "async-trait", + "auth", + "axum", + "domain", + "dotenvy", + "event-transport", + "http 1.4.0", + "nats", + "postgres", + "postgres-federation", + "postgres-search", + "presentation", + "sqlx", + "tokio", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "domain" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "futures", + "hex", + "serde", + "sha2", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum_delegate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ea75f31022cba043afe037940d73684327e915f88f62478e778c3de914cd0a" +dependencies = [ + "enum_delegate_lib", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum_delegate_lib" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1f6c3800b304a6be0012039e2a45a322a093539c45ab818d9e6895a39c90fe" +dependencies = [ + "proc-macro2", + "quote", + "rand 0.8.6", + "syn 1.0.109", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "event-payload" +version = "0.1.0" +dependencies = [ + "domain", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "event-transport" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "event-payload", + "futures", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-signature-normalization" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95e3149194de5f3f9d5225bcc6a8677979f8ff8ce39c85654730ad4824f101e" +dependencies = [ + "httpdate", +] + +[[package]] +name = "http-signature-normalization-reqwest" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2441a67ea8984d46c95099b4a9be83dc5bed2c254b8443dc2554edaeaa7d0b" +dependencies = [ + "async-trait", + "base64", + "http-signature-normalization", + "httpdate", + "reqwest", + "reqwest-middleware", + "sha2", + "tokio", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nats" +version = "0.1.0" +dependencies = [ + "async-nats", + "async-stream", + "async-trait", + "domain", + "event-payload", + "event-transport", + "futures", + "serde_json", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postgres" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "async-trait", + "chrono", + "domain", + "event-payload", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "postgres-federation" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "anyhow", + "async-trait", + "chrono", + "sqlx", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "postgres-search" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "postgres", + "sqlx", + "tokio", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presentation" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "api-types", + "application", + "async-trait", + "axum", + "chrono", + "domain", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "utoipa", + "utoipa-scalar", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.0", + "reqwest", + "thiserror 2.0.18", + "tower-service", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.8.6", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.3", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worker" +version = "0.1.0" +dependencies = [ + "activitypub", + "activitypub-base", + "application", + "async-nats", + "domain", + "dotenvy", + "event-payload", + "event-transport", + "futures", + "nats", + "postgres", + "postgres-federation", + "serde_json", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..783e280 --- /dev/null +++ b/Cargo.toml @@ -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.48" +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" } diff --git a/Database schema.md b/Database schema.md deleted file mode 100644 index 6685480..0000000 --- a/Database schema.md +++ /dev/null @@ -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. | - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7830f15 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31bafd2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..061413a --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Thoughts + +A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend. + +## Features + +- Short-form posts (thoughts) with replies, boosts, and likes +- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync +- **Remote actor discovery** — search by `@user@instance` handle, view full remote profiles (bio, banner, profile fields, posts, followers, following tabs), follow from within the UI +- **Worker-backed remote caches** — remote posts and follower/following lists are fetched by the NATS worker and cached locally; profiles populate on first visit and refresh in the background +- Content negotiation at `GET /users/{username}` — serves ActivityPub actor JSON or REST profile based on `Accept` header +- Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes +- Async event fan-out via NATS JetStream — notifications and AP delivery run in a separate worker process; pull consumer with 1-hour TTL caching +- JWT authentication (Bearer token) +- 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 ` obtained from `POST /auth/login`. + +Interactive API documentation is available at runtime: + +- **Swagger UI** — `http://localhost:8000/docs` +- **Scalar** — `http://localhost:8000/scalar` + +## Frontend + +The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables: + +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests +NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests +``` + +```bash +cd thoughts-frontend +bun install +bun run dev # http://localhost:3000 +``` + +## Docker + +The backend image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers: + +```bash +docker build -t thoughts . + +# API server +docker run -p 8000:8000 \ + -e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \ + -e JWT_SECRET=change-me \ + -e BASE_URL=https://yourdomain.example.com \ + -e NATS_URL=nats://nats:4222 \ + thoughts + +# Event worker (same image, different entrypoint) +docker run \ + -e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \ + -e BASE_URL=https://yourdomain.example.com \ + -e NATS_URL=nats://nats:4222 \ + --entrypoint ./thoughts-worker \ + thoughts + +# Frontend +docker build -t thoughts-frontend \ + --build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \ + --build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \ + thoughts-frontend/ +docker run -p 3000:3000 thoughts-frontend +``` + +See `compose.yml` for a full local development stack. + +## License + +MIT License. See [LICENSE](LICENSE). diff --git a/codebase-prompt.txt b/codebase-prompt.txt deleted file mode 100644 index a0514ba..0000000 --- a/codebase-prompt.txt +++ /dev/null @@ -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" \ No newline at end of file diff --git a/compose.prod.yml b/compose.prod.yml index da2555f..5036c02 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -1,6 +1,6 @@ services: database: - image: postgres:15-alpine + image: postgres:16-alpine container_name: thoughts-db restart: unless-stopped environment: @@ -17,19 +17,21 @@ services: networks: - internal - backend: - container_name: thoughts-backend - image: thoughts-backend:latest + api: + container_name: thoughts-api + image: registry.gabrielkaszewski.dev/thoughts:latest restart: unless-stopped environment: - - RUST_LOG=info - - RUST_BACKTRACE=1 - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB} - - HOST=0.0.0.0 - - PORT=8000 - - PREFORK=1 - - AUTH_SECRET=${AUTH_SECRET} - - BASE_URL=https://thoughts.gabrielkaszewski.dev + RUST_LOG: info + RUST_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB} + HOST: 0.0.0.0 + PORT: 8000 + JWT_SECRET: ${JWT_SECRET} + BASE_URL: ${BASE_URL} + NATS_URL: nats://k_nats:4222 + CORS_ORIGINS: ${CORS_ORIGINS:-*} + ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} depends_on: database: condition: service_healthy @@ -40,34 +42,51 @@ services: retries: 5 networks: - internal + - shared-services + - traefik + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik" + - "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)" + - "traefik.http.routers.thoughts-api.entrypoints=web,websecure" + - "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt" + - "traefik.http.routers.thoughts-api.service=thoughts-api" + - "traefik.http.services.thoughts-api.loadbalancer.server.port=8000" + + worker: + container_name: thoughts-worker + image: registry.gabrielkaszewski.dev/thoughts:latest + entrypoint: ["./thoughts-worker"] + restart: unless-stopped + environment: + RUST_LOG: info + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB} + BASE_URL: ${BASE_URL} + NATS_URL: nats://k_nats:4222 + depends_on: + database: + condition: service_healthy + networks: + - internal + - shared-services frontend: container_name: thoughts-frontend - image: thoughts-frontend:latest + image: registry.gabrielkaszewski.dev/thoughts-frontend:latest restart: unless-stopped + environment: + NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000 + NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev + PORT: 3000 + HOSTNAME: 0.0.0.0 depends_on: - - backend + api: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 10s timeout: 5s retries: 5 - environment: - - NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api - - PORT=3000 - - HOSTNAME=0.0.0.0 - networks: - - internal - - proxy: - container_name: thoughts-proxy - image: custom-proxy:latest - restart: unless-stopped - depends_on: - frontend: - condition: service_healthy - backend: - condition: service_healthy networks: - internal - traefik @@ -78,14 +97,16 @@ services: - "traefik.http.routers.thoughts.entrypoints=web,websecure" - "traefik.http.routers.thoughts.tls.certresolver=letsencrypt" - "traefik.http.routers.thoughts.service=thoughts" - - "traefik.http.services.thoughts.loadbalancer.server.port=80" + - "traefik.http.services.thoughts.loadbalancer.server.port=3000" volumes: postgres_data: driver: local + networks: + shared-services: + external: true traefik: - name: traefik external: true internal: driver: bridge diff --git a/compose.yml b/compose.yml index e45acc8..06cf13a 100644 --- a/compose.yml +++ b/compose.yml @@ -1,77 +1,67 @@ services: - database: - image: postgres:15-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 + postgres: + image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres + POSTGRES_DB: thoughts ports: - - "5434:5432" + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] - interval: 10s + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s timeout: 5s retries: 5 + nats: + image: nats:2-alpine + ports: + - "4222:4222" + - "8222:8222" # monitoring endpoint + command: ["--jetstream", "--http_port", "8222"] + + api: + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts + JWT_SECRET: change-me-in-production + BASE_URL: http://localhost:8000 + NATS_URL: nats://nats:4222 + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + nats: + condition: service_started + + worker: + build: . + entrypoint: ["./thoughts-worker"] + environment: + DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts + BASE_URL: http://localhost:8000 + NATS_URL: nats://nats:4222 + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + nats: + condition: service_started + + frontend: + build: + context: ./thoughts-frontend + args: + NEXT_PUBLIC_API_URL: http://localhost:8000 + NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000 + ports: + - "3000:3000" + depends_on: + - api + volumes: postgres_data: - driver: local diff --git a/crates/adapters/activitypub-base/Cargo.toml b/crates/adapters/activitypub-base/Cargo.toml new file mode 100644 index 0000000..3efc249 --- /dev/null +++ b/crates/adapters/activitypub-base/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "activitypub-base" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } +domain = { workspace = true } + +activitypub_federation = "0.7.0-beta.11" +enum_delegate = "0.2" diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs new file mode 100644 index 0000000..2c96fde --- /dev/null +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -0,0 +1,851 @@ +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; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename = "Like")] +pub struct LikeType; + +impl Default for LikeType { + fn default() -> Self { + Self + } +} + +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, + pub(crate) object: ObjectId, +} + +#[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) -> 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) -> 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, + 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) -> Result<(), Self::Error> { + if self.actor.inner() != self.object.object.inner() { + return Err(Error::bad_request(anyhow::anyhow!( + "Accept actor does not match Follow target" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> 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, + 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) -> Result<(), Self::Error> { + if self.actor.inner() != self.object.object.inner() { + return Err(Error::bad_request(anyhow::anyhow!( + "Reject actor does not match Follow target" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> 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, + 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) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> 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)"); + } + } + "Like" => { + if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str()) + && let Ok(obj_url) = Url::parse(obj_url_str) + && obj_url.host_str().unwrap_or("") == data.domain + { + data.object_handler + .on_unlike(&obj_url, self.actor.inner()) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process unlike"); + }); + } + tracing::info!(actor = %self.actor.inner(), "received Undo(Like)"); + } + 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, + pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) bto: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) bcc: Vec, +} + +#[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) -> Result<(), Self::Error> { + if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) + && let Ok(attributed_url) = Url::parse(attributed_to) + && &attributed_url != self.actor.inner() + { + return Err(Error::bad_request(anyhow::anyhow!( + "Create actor does not match object attributedTo" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> 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(()); + } + // Use the Note's own id, not the Create activity id (which ends in /activity). + // Delete activities reference the Note id, so they must match. + let ap_id = self + .object + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Url::parse(s).ok()) + .unwrap_or_else(|| 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, + pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, +} + +#[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) -> Result<(), Self::Error> { + let actor_domain = self.actor.inner().host_str().unwrap_or(""); + let object_domain = match &self.object { + serde_json::Value::String(s) => Url::parse(s) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(), + serde_json::Value::Object(o) => o + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Url::parse(s).ok()) + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(), + _ => String::new(), + }; + if !object_domain.is_empty() && actor_domain != object_domain { + return Err(Error::bad_request(anyhow::anyhow!( + "Delete actor domain does not match object domain" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> 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, + pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, +} + +#[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) -> Result<(), Self::Error> { + if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) + && let Ok(attributed_url) = Url::parse(attributed_to) + && &attributed_url != self.actor.inner() + { + return Err(Error::bad_request(anyhow::anyhow!( + "Update actor does not match object attributedTo" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> 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 + .object + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Url::parse(s).ok()) + .unwrap_or_else(|| 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, + pub(crate) object: Url, + pub(crate) published: Option>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, +} + +#[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) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> 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 { + tracing::debug!( + actor = %self.actor.inner(), + object = %self.object, + "received Announce of non-local object — skipped (cross-server boost not supported)" + ); + 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?; + data.object_handler + .on_announce_received(&self.object, self.actor.inner()) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process announce notification"); + }); + tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce"); + Ok(()) + } +} + +// --- Like --- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LikeActivity { + pub id: Url, + #[serde(rename = "type")] + pub kind: LikeType, + pub actor: ObjectId, + pub object: Url, +} + +#[async_trait::async_trait] +impl Activity for LikeActivity { + type DataType = FederationData; + type Error = crate::error::Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> 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 Like from blocked domain"); + return Ok(()); + } + + // Only process if the liked object is on our instance. + if self.object.host_str().unwrap_or("") != data.domain { + return Ok(()); + } + + data.object_handler + .on_like(&self.object, self.actor.inner()) + .await + .map_err(|e| crate::error::Error::from(anyhow::anyhow!(e)))?; + + tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like"); + 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, + pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, +} + +#[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) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> 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, + 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) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> 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(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; + let _ = data + .federation_repo + .remove_follower(local_user_id, self.actor.inner().as_str()) + .await; + } + tracing::info!(actor = %self.actor.inner(), "received block — removed following and follower"); + Ok(()) + } +} + +// --- Move (account migration) --- + +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(rename = "Move")] +pub struct MoveType; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: MoveType, + pub(crate) actor: ObjectId, + pub(crate) object: Url, + pub(crate) target: Url, +} + +#[async_trait::async_trait] +impl Activity for MoveActivity { + 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) -> Result<(), Self::Error> { + if &self.object != self.actor.inner() { + return Err(Error::bad_request(anyhow::anyhow!( + "Move object must be the actor itself" + ))); + } + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + return Ok(()); + } + tracing::info!( + actor = %self.actor.inner(), + target = %self.target, + "received Move (account migration) — target noted" + ); + 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), + #[serde(rename = "Like")] + Like(LikeActivity), + #[serde(rename = "Move")] + Move(MoveActivity), +} diff --git a/crates/adapters/activitypub-base/src/actor_handler.rs b/crates/adapters/activitypub-base/src/actor_handler.rs new file mode 100644 index 0000000..7030967 --- /dev/null +++ b/crates/adapters/activitypub-base/src/actor_handler.rs @@ -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, + data: Data, +) -> Result>, 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))) +} diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs new file mode 100644 index 0000000..e321485 --- /dev/null +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -0,0 +1,372 @@ +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, + pub inbox_url: Url, + pub shared_inbox_url: Option, + pub outbox_url: Url, + pub followers_url: Url, + pub following_url: Url, + pub ap_id: Url, + pub last_refreshed_at: DateTime, + pub bio: Option, + pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub profile_url: Option, + pub attachment: Vec, +} + +#[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, + preferred_username: String, + inbox: Url, + outbox: Url, + followers: Url, + following: Url, + public_key: PublicKey, + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + discoverable: Option, + manually_approves_followers: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + updated: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + endpoints: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)] + also_known_as: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + attachment: Vec, +} + +struct ActorUrls { + ap_id: Url, + inbox_url: Url, + shared_inbox_url: Option, + outbox_url: Url, + followers_url: Url, + following_url: Url, +} + +impl ActorUrls { + fn build(base_url: &str, user_id: uuid::Uuid) -> Self { + let ap_id = crate::urls::actor_url(base_url, user_id); + Self { + inbox_url: Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"), + shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(), + outbox_url: Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"), + followers_url: Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"), + following_url: Url::parse(&format!("{}/following", &ap_id)).expect("valid url"), + ap_id, + } + } +} + +pub async fn get_local_actor( + user_id: uuid::Uuid, + data: &Data, +) -> Result { + 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 ActorUrls { + ap_id, + inbox_url, + shared_inbox_url, + outbox_url, + followers_url, + following_url, + } = ActorUrls::build(&data.base_url, user_id); + + Ok(DbActor { + user_id, + username: user.username, + public_key_pem: public_key, + private_key_pem: Some(private_key), + inbox_url, + shared_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> { + Some(self.last_refreshed_at) + } + + async fn read_from_id( + object_id: Url, + data: &Data, + ) -> Result, 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 ActorUrls { + ap_id, + inbox_url, + shared_inbox_url, + outbox_url, + followers_url, + following_url, + } = ActorUrls::build(&data.base_url, user_id); + + Ok(Some(DbActor { + user_id, + username: user.username, + public_key_pem: public_key, + private_key_pem: private_key, + inbox_url, + shared_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) -> Result { + 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 = self.also_known_as.into_iter().collect(); + let attachment: Vec = 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, + ) -> Result<(), Self::Error> { + verify_domains_match(json.id.inner(), expected_domain)?; + Ok(()) + } + + async fn from_json(json: Self::Kind, data: &Data) -> Result { + let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string()); + let actor = RemoteActor { + url: json.id.inner().to_string(), + handle: json.preferred_username.clone(), + inbox_url: json.inbox.to_string(), + shared_inbox_url, + 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 shared_inbox_url = json + .endpoints + .as_ref() + .and_then(|e| Url::parse(e.shared_inbox.as_str()).ok()); + 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, + shared_inbox_url, + outbox_url, + followers_url, + following_url, + ap_id, + last_refreshed_at: Utc::now(), + bio: json.summary.clone(), + avatar_url: json.icon.as_ref().map(|i| i.url.clone()), + banner_url: json.image.as_ref().map(|i| i.url.clone()), + also_known_as: json.also_known_as.into_iter().next(), + profile_url: json.url.clone(), + attachment: json + .attachment + .iter() + .map(|f| crate::user::ApProfileField { + name: f.name.clone(), + value: f.value.clone(), + }) + .collect(), + }) + } +} + +impl Actor for DbActor { + fn public_key_pem(&self) -> &str { + &self.public_key_pem + } + + fn private_key_pem(&self) -> Option { + self.private_key_pem.clone() + } + + fn inbox(&self) -> Url { + self.inbox_url.clone() + } +} + +#[cfg(test)] +#[path = "tests/actors.rs"] +mod tests; diff --git a/crates/adapters/activitypub-base/src/ap_ports.rs b/crates/adapters/activitypub-base/src/ap_ports.rs new file mode 100644 index 0000000..15190c0 --- /dev/null +++ b/crates/adapters/activitypub-base/src/ap_ports.rs @@ -0,0 +1,167 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::thought::Thought, + value_objects::{ThoughtId, UserId, Username}, +}; + +/// AP-protocol endpoints for a locally-stored user (local or interned remote). +#[derive(Debug, Clone)] +pub struct ActorApUrls { + pub ap_id: String, + pub inbox_url: String, +} + +/// A local thought ready for AP serialization, with the author's username +/// pre-joined so the handler can build AP URLs without a second query. +#[derive(Debug, Clone)] +pub struct OutboxEntry { + pub thought: Thought, + pub author_username: Username, +} + +#[async_trait] +pub trait ActivityPubRepository: Send + Sync { + // ── Outbox (local → remote) ────────────────────────────────────── + + /// All public local thoughts for this actor. Used for outbox totals + /// and full-collection delivery. + async fn outbox_entries_for_actor( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + + /// Cursor page of public local thoughts, newest-first, before `before`. + /// Used for OrderedCollectionPage responses. + async fn outbox_page_for_actor( + &self, + user_id: &UserId, + before: Option>, + limit: usize, + ) -> Result, DomainError>; + + // ── Remote actor resolution ────────────────────────────────────── + + /// Find the local UserId for a remote actor by its AP URL. + async fn find_remote_actor_id(&self, actor_ap_url: &str) + -> Result, DomainError>; + + /// Ensure a remote actor placeholder exists; create one if absent. + /// Idempotent — safe to call multiple times with the same URL. + async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result; + + /// Update display_name and avatar_url for an already-interned remote actor. + async fn update_remote_actor_display( + &self, + user_id: &UserId, + display_name: Option<&str>, + avatar_url: Option<&str>, + ) -> Result<(), DomainError>; + + // ── Inbox processing (remote → local) ─────────────────────────── + + /// Persist an incoming remote Note. Idempotent on ap_id. + #[allow(clippy::too_many_arguments)] + async fn accept_note( + &self, + ap_id: &str, + author_id: &UserId, + content: &str, + published: chrono::DateTime, + sensitive: bool, + content_warning: Option, + visibility: &str, + in_reply_to: Option<&str>, + ) -> Result; + + /// Apply an Update to a previously accepted remote Note. + async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>; + + /// Remove a specific remote Note (Delete activity). Only touches + /// remotely-originated thoughts. + async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>; + + /// Remove all Notes from a remote actor (actor-level Delete/Tombstone). + async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>; + + // ── Node-level stats ───────────────────────────────────────────── + + /// Total locally-authored thought count for NodeInfo responses. + async fn count_local_notes(&self) -> Result; + + /// Return the ActivityPub object URL for a thought, if one is stored. + /// Returns None for local thoughts (caller constructs URL from base_url + thought_id). + async fn get_thought_ap_id( + &self, + thought_id: &ThoughtId, + ) -> Result, DomainError>; + + /// Return the AP actor URL and inbox URL for a user, if stored. + /// Returns None for users that have not been federated. + async fn get_actor_ap_urls(&self, user_id: &UserId) + -> Result, DomainError>; +} + +#[async_trait] +pub trait OutboundFederationPort: Send + Sync { + /// Fan out a new local Note to all accepted followers. + async fn broadcast_create( + &self, + author_user_id: &UserId, + thought: &Thought, + author_username: &str, + in_reply_to_url: Option<&str>, + ) -> Result<(), DomainError>; + + /// Fan out a Delete tombstone for a now-deleted local Note. + /// `thought_ap_id` is pre-constructed by the caller because the thought + /// has already been deleted from the DB when this fires. + async fn broadcast_delete( + &self, + author_user_id: &UserId, + thought_ap_id: &str, + ) -> Result<(), DomainError>; + + /// Fan out an Update(Note) for an edited local thought. + async fn broadcast_update( + &self, + author_user_id: &UserId, + thought: &Thought, + author_username: &str, + in_reply_to_url: Option<&str>, + ) -> Result<(), DomainError>; + + /// Fan out an Announce(object_ap_id) for a boost. + async fn broadcast_announce( + &self, + booster_user_id: &UserId, + object_ap_id: &str, + ) -> Result<(), DomainError>; + + /// Fan out an Undo(Announce) to followers when a boost is removed. + async fn broadcast_undo_announce( + &self, + booster_user_id: &UserId, + object_ap_id: &str, + ) -> Result<(), DomainError>; + + /// Send a Like activity to a remote thought author's inbox. + /// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id). + async fn broadcast_like( + &self, + liker_user_id: &UserId, + object_ap_id: &str, + author_inbox_url: &str, + ) -> Result<(), DomainError>; + + /// Send Undo(Like) to a remote thought author's inbox. + async fn broadcast_undo_like( + &self, + liker_user_id: &UserId, + object_ap_id: &str, + author_inbox_url: &str, + ) -> Result<(), DomainError>; + + /// Fan out an Update(Actor) to all accepted followers after a profile change. + async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>; +} diff --git a/crates/adapters/activitypub-base/src/content.rs b/crates/adapters/activitypub-base/src/content.rs new file mode 100644 index 0000000..e6156d9 --- /dev/null +++ b/crates/adapters/activitypub-base/src/content.rs @@ -0,0 +1,68 @@ +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>; + + /// 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>, + limit: usize, + ) -> anyhow::Result)>>; + + /// 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<()>; + + /// Called when a remote actor likes a local thought. + /// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`). + /// `actor_url` is the AP URL of the remote actor who sent the Like. + async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// Called when a remote actor boosts (Announce) a local thought. + /// `object_url` is the AP URL of the announced note. + /// `actor_url` is the AP URL of the remote actor who sent the Announce. + async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// Called when a remote actor removes a Like from a local thought. + async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// Called when an inbound Note tags a local user with a Mention. + async fn on_mention( + &self, + thought_ap_id: &Url, + mentioned_user_uuid: uuid::Uuid, + actor_url: &Url, + ) -> anyhow::Result<()>; + + /// Total number of locally-authored posts across all users. + async fn count_local_posts(&self) -> anyhow::Result; +} diff --git a/crates/adapters/activitypub-base/src/data.rs b/crates/adapters/activitypub-base/src/data.rs new file mode 100644 index 0000000..2f3497c --- /dev/null +++ b/crates/adapters/activitypub-base/src/data.rs @@ -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, + pub(crate) user_repo: Arc, + pub(crate) object_handler: Arc, + 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>, +} + +impl FederationData { + pub fn new( + federation_repo: Arc, + user_repo: Arc, + object_handler: Arc, + base_url: String, + allow_registration: bool, + software_name: String, + event_publisher: Option>, + ) -> 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, + } + } +} diff --git a/crates/adapters/activitypub-base/src/error.rs b/crates/adapters/activitypub-base/src/error.rs new file mode 100644 index 0000000..d631755 --- /dev/null +++ b/crates/adapters/activitypub-base/src/error.rs @@ -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) -> Self { + Self(e.into(), StatusCode::NOT_FOUND) + } + + pub fn bad_request(e: impl Into) -> 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 From for Error +where + T: Into, +{ + 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() + } +} diff --git a/crates/adapters/activitypub-base/src/federation.rs b/crates/adapters/activitypub-base/src/federation.rs new file mode 100644 index 0000000..23d59e2 --- /dev/null +++ b/crates/adapters/activitypub-base/src/federation.rs @@ -0,0 +1,49 @@ +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); + +impl ApFederationConfig { + pub async fn new(data: FederationData, debug: bool) -> anyhow::Result { + 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) + .build() + .await? + }; + Ok(Self(config)) + } + + pub fn to_request_data(&self) -> Data { + self.0.to_request_data() + } + + pub fn middleware(&self) -> FederationMiddleware { + FederationMiddleware::new(self.0.clone()) + } +} diff --git a/crates/adapters/activitypub-base/src/followers_handler.rs b/crates/adapters/activitypub-base/src/followers_handler.rs new file mode 100644 index 0000000..e5de463 --- /dev/null +++ b/crates/adapters/activitypub-base/src/followers_handler.rs @@ -0,0 +1,105 @@ +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; +use crate::urls::AP_PAGE_SIZE; + +#[derive(Deserialize)] +pub struct PageQuery { + page: Option, +} + +async fn collection_handler( + user_id_str: &str, + query: PageQuery, + data: Data, + collection_type: &str, +) -> Result, 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/{}/{}", + data.base_url, user_id_str, collection_type + ); + + let total = match collection_type { + "followers" => data.federation_repo.count_followers(user_id).await, + _ => 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) * AP_PAGE_SIZE; + + let items: Vec = match collection_type { + "followers" => data + .federation_repo + .get_followers_page(user_id, offset as u32, AP_PAGE_SIZE) + .await + .map_err(Error::from)? + .into_iter() + .map(|f| f.actor.url) + .collect(), + _ => data + .federation_repo + .get_following_page(user_id, offset as u32, AP_PAGE_SIZE) + .await + .map_err(Error::from)? + .into_iter() + .map(|a| a.url) + .collect(), + }; + + let has_next = offset + items.len() < total; + + let mut obj = json!({ + "@context": crate::urls::AP_CONTEXT, + "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": crate::urls::AP_CONTEXT, + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }))) + } +} + +pub async fn followers_handler( + Path(user_id_str): Path, + Query(query): Query, + data: Data, +) -> Result, Error> { + collection_handler(&user_id_str, query, data, "followers").await +} + +pub async fn following_handler( + Path(user_id_str): Path, + Query(query): Query, + data: Data, +) -> Result, Error> { + collection_handler(&user_id_str, query, data, "following").await +} diff --git a/crates/adapters/activitypub-base/src/inbox.rs b/crates/adapters/activitypub-base/src/inbox.rs new file mode 100644 index 0000000..2f2d063 --- /dev/null +++ b/crates/adapters/activitypub-base/src/inbox.rs @@ -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, + activity_data: ActivityData, +) -> Result<(), Error> { + receive_activity::, DbActor, FederationData>(activity_data, &data) + .await +} diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs new file mode 100644 index 0000000..aceb0d5 --- /dev/null +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -0,0 +1,30 @@ +pub mod activities; +pub mod actor_handler; +pub mod actors; +pub mod ap_ports; +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 ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort}; +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}; diff --git a/crates/adapters/activitypub-base/src/nodeinfo.rs b/crates/adapters/activitypub-base/src/nodeinfo.rs new file mode 100644 index 0000000..f619d75 --- /dev/null +++ b/crates/adapters/activitypub-base/src/nodeinfo.rs @@ -0,0 +1,82 @@ +use activitypub_federation::config::Data; +use axum::Json; +use serde::Serialize; + +use crate::data::FederationData; +use crate::error::Error; + +const NODEINFO_2_0_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0"; + +#[derive(Serialize)] +pub struct NodeInfoWellKnown { + pub links: Vec, +} + +#[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, + pub usage: NodeInfoUsage, + pub open_registrations: bool, +} + +pub async fn nodeinfo_well_known_handler( + data: Data, +) -> Result, Error> { + let href = format!("{}/nodeinfo/2.0", data.base_url); + Ok(Json(NodeInfoWellKnown { + links: vec![NodeInfoLink { + rel: NODEINFO_2_0_REL.to_string(), + href, + }], + })) +} + +pub async fn nodeinfo_handler(data: Data) -> Result, 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; diff --git a/crates/adapters/activitypub-base/src/outbox.rs b/crates/adapters/activitypub-base/src/outbox.rs new file mode 100644 index 0000000..80f004e --- /dev/null +++ b/crates/adapters/activitypub-base/src/outbox.rs @@ -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, urls::AP_PAGE_SIZE}; + +#[derive(Deserialize)] +pub struct OutboxQuery { + page: Option, + before: Option, +} + +#[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(skip_serializing_if = "Option::is_none")] + next: Option, +} + +pub async fn outbox_handler( + Path(user_id_str): Path, + Query(query): Query, + data: Data, +) -> Result { + 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> = query.before.as_deref().and_then(|s| s.parse().ok()); + + let items = data + .object_handler + .get_local_objects_page(uuid, before, AP_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() == AP_PAGE_SIZE; + let oldest_ts = items.last().map(|(_, _, ts)| *ts); + + let followers_url = format!("{}/followers", actor_url); + let ordered_items: Vec = 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()], + bto: vec![], + bcc: vec![], + })) + .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: crate::urls::AP_CONTEXT.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: crate::urls::AP_CONTEXT.to_string(), + kind: "OrderedCollection".to_string(), + id: outbox_url.clone(), + total_items: total, + first: format!("{}?page=true", outbox_url), + }) + .into_response()) + } +} diff --git a/crates/adapters/activitypub-base/src/repository.rs b/crates/adapters/activitypub-base/src/repository.rs new file mode 100644 index 0000000..0aab3c2 --- /dev/null +++ b/crates/adapters/activitypub-base/src/repository.rs @@ -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, + pub display_name: Option, + pub avatar_url: Option, + pub outbox_url: Option, +} + +#[derive(Debug, Clone)] +pub struct Follower { + pub actor: RemoteActor, + pub status: FollowerStatus, +} + +#[derive(Debug, Clone)] +pub struct BlockedDomain { + pub domain: String, + pub reason: Option, + 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>; + 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>; + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result; + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; + 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>; + 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>; + async fn count_following(&self, local_user_id: uuid::Uuid) -> Result; + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>; + async fn get_remote_actor(&self, actor_url: &str) -> Result>; + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result>; + 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>; + 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>; + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()>; + async fn count_announces(&self, object_url: &str) -> Result; + 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>; + async fn is_domain_blocked(&self, domain: &str) -> Result; + 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>; + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result; +} diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs new file mode 100644 index 0000000..ff2f22f --- /dev/null +++ b/crates/adapters/activitypub-base/src/service.rs @@ -0,0 +1,2086 @@ +use std::sync::Arc; + +use activitypub_federation::{ + activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext, + traits::Actor, +}; +use axum::{Router, routing::get, routing::post}; +use url::Url; + +use crate::{ + activities::{ + AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, + UpdateActivity, + }, + actors::{DbActor, get_local_actor}, + content::ApObjectHandler, + data::FederationData, + federation::ApFederationConfig, + inbox::inbox_handler, + nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, + outbox::outbox_handler, + repository::{ + BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor, + }, + urls::activity_url, + user::ApUserRepository, + webfinger::webfinger_handler, +}; + +const DELIVERY_MAX_ATTEMPTS: u32 = 3; +const DELIVERY_INITIAL_DELAY_SECS: u64 = 1; +const HTTP_FETCH_TIMEOUT_SECS: u64 = 30; +const BATCH_FETCH_SLEEP_MS: u64 = 100; + +fn content_to_html(text: &str) -> String { + let escaped = text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect(); + if paragraphs.is_empty() { + format!("

{}

", escaped) + } else { + paragraphs + .iter() + .map(|p| format!("

{}

", p)) + .collect::>() + .join("") + } +} + +fn thought_note_json( + thought: &domain::models::thought::Thought, + local_actor: &crate::actors::DbActor, + base_url: &str, + in_reply_to_url: Option<&str>, +) -> anyhow::Result<(url::Url, serde_json::Value)> { + let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?; + + // Build to/cc based on visibility per AP spec. + let (to, cc) = match thought.visibility { + domain::models::thought::Visibility::Public => ( + vec![crate::urls::AS_PUBLIC.to_string()], + vec![local_actor.followers_url.to_string()], + ), + domain::models::thought::Visibility::Unlisted => ( + vec![local_actor.followers_url.to_string()], + vec![crate::urls::AS_PUBLIC.to_string()], + ), + domain::models::thought::Visibility::Followers => { + (vec![local_actor.followers_url.to_string()], vec![]) + } + domain::models::thought::Visibility::Direct => (vec![], vec![]), + }; + + let mut note = serde_json::json!({ + "type": "Note", + "id": ap_id.to_string(), + "url": ap_id.to_string(), + "attributedTo": local_actor.ap_id.to_string(), + "content": content_to_html(thought.content.as_str()), + "published": thought.created_at.to_rfc3339(), + "to": to, + "cc": cc, + "sensitive": thought.sensitive, + }); + if let Some(ref cw) = thought.content_warning { + note["summary"] = serde_json::json!(cw); + } + if let Some(reply_url) = in_reply_to_url { + note["inReplyTo"] = serde_json::json!(reply_url); + } + if let Some(updated_at) = thought.updated_at { + note["updated"] = serde_json::json!(updated_at.to_rfc3339()); + } + let hashtags = domain::hashtag::extract(thought.content.as_str()); + if !hashtags.is_empty() { + let ap_tags: Vec = hashtags + .iter() + .map(|h| { + serde_json::json!({ + "type": "Hashtag", + "name": h.ap_name, + "href": format!("{}/{}", base_url, h.url_slug), + }) + }) + .collect(); + note["tag"] = serde_json::json!(ap_tags); + } + Ok((ap_id, note)) +} + +fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut inboxes = Vec::new(); + for f in followers { + let inbox_str = f + .actor + .shared_inbox_url + .as_deref() + .unwrap_or(&f.actor.inbox_url); + if seen.insert(inbox_str.to_string()) + && let Ok(url) = Url::parse(inbox_str) + { + inboxes.push(url); + } + } + inboxes +} + +pub(crate) async fn send_with_retry( + sends: Vec, + data: &activitypub_federation::config::Data, +) -> Vec { + let mut failures = vec![]; + for send in sends { + let mut delay = std::time::Duration::from_secs(DELIVERY_INITIAL_DELAY_SECS); + for attempt in 1..=DELIVERY_MAX_ATTEMPTS { + match send.clone().sign_and_send(data).await { + Ok(()) => break, + Err(e) if attempt < DELIVERY_MAX_ATTEMPTS => { + tracing::warn!(attempt, error = %e, "delivery failed, retrying"); + tokio::time::sleep(delay).await; + delay *= 2; + } + Err(e) => { + tracing::error!(attempt, error = %e, "delivery failed permanently"); + failures.push(anyhow::anyhow!(e)); + } + } + } + } + failures +} + +pub struct ActivityPubService { + federation_config: ApFederationConfig, + base_url: String, +} + +impl ActivityPubService { + #[allow(clippy::too_many_arguments)] + pub async fn new( + repo: Arc, + user_repo: Arc, + object_handler: Arc, + base_url: String, + allow_registration: bool, + software_name: String, + debug: bool, + event_publisher: Option>, + ) -> anyhow::Result { + let data = FederationData::new( + repo, + user_repo, + object_handler, + base_url.clone(), + allow_registration, + software_name, + event_publisher, + ); + let federation_config = ApFederationConfig::new(data, debug).await?; + Ok(Self { + federation_config, + base_url, + }) + } + + pub fn federation_config(&self) -> &ApFederationConfig { + &self.federation_config + } + + pub fn request_data(&self) -> activitypub_federation::config::Data { + self.federation_config.to_request_data() + } + + /// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers, + /// excluding blocked actors and blocked domains. + /// Returns `None` if there are no eligible followers. + async fn accepted_follower_inboxes( + &self, + data: &activitypub_federation::config::Data, + local_user_id: uuid::Uuid, + ) -> anyhow::Result)>> { + let local_actor = get_local_actor(local_user_id, data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data + .federation_repo + .get_blocked_actors(local_user_id) + .await + .unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data + .federation_repo + .get_blocked_domains() + .await + .unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(None); + } + + Ok(Some((local_actor, collect_inboxes(&accepted)))) + } + + pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result { + use activitypub_federation::traits::Object; + let uuid = uuid::Uuid::parse_str(user_id_str)?; + let data = self.federation_config.to_request_data(); + let actor = get_local_actor(uuid, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let person = actor + .into_json(&data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(serde_json::to_string(&WithContext::new_default(person))?) + } + + /// Returns the ActivityPub router compatible with any outer state `S`. + /// Handlers only use `Data` injected by the middleware layer, + /// so the router is independent of the application state type. + pub fn router(&self) -> Router + where + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler)) + .route("/nodeinfo/2.0", get(nodeinfo_handler)) + .route("/.well-known/webfinger", get(webfinger_handler)) + .route("/inbox", post(inbox_handler)) + .route("/users/{id}/inbox", post(inbox_handler)) + .route("/users/{id}/outbox", get(outbox_handler)) + .layer(self.federation_config.middleware()) + } + + /// Fan out an Announce activity to all accepted followers. + pub async fn broadcast_announce_to_followers( + &self, + local_user_id: uuid::Uuid, + object_ap_id: url::Url, + ) -> anyhow::Result<()> { + // Deterministic ID so Undo(Announce) can reference this same activity. + let announce_id = url::Url::parse(&format!( + "{}/activities/announce/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", local_user_id, object_ap_id).as_bytes(), + ) + )) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { + return Ok(()); + }; + + let announce = crate::activities::AnnounceActivity { + id: announce_id, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), + object: object_ap_id, + published: Some(chrono::Utc::now()), + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(announce), + &local_actor, + inboxes, + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Announce deliveries failed"); + } + Ok(()) + } + + /// Fan out an Undo(Announce) activity to all accepted followers. + pub async fn broadcast_undo_announce_to_followers( + &self, + local_user_id: uuid::Uuid, + object_ap_id: url::Url, + ) -> anyhow::Result<()> { + // Reconstruct the same deterministic announce ID used when the boost was sent. + let announce_id = url::Url::parse(&format!( + "{}/activities/announce/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", local_user_id, object_ap_id).as_bytes(), + ) + )) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let undo_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { + return Ok(()); + }; + + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), + object: serde_json::json!({ + "type": "Announce", + "id": announce_id.to_string(), + "actor": local_actor.ap_id.to_string(), + "object": object_ap_id.to_string(), + }), + }; + + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(undo), + &local_actor, + inboxes, + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some Undo(Announce) deliveries failed" + ); + } + Ok(()) + } + + /// Send a Like activity to a single inbox. + pub async fn broadcast_like_to_inbox( + &self, + liker_user_id: uuid::Uuid, + object_ap_id: url::Url, + author_inbox_url: url::Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(liker_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Deterministic ID so Undo(Like) can reference the same activity. + let like_id = url::Url::parse(&format!( + "{}/activities/like/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", liker_user_id, object_ap_id).as_bytes(), + ) + ))?; + + let like = crate::activities::LikeActivity { + id: like_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: object_ap_id, + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(like), + &local_actor, + vec![author_inbox_url], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some Like deliveries failed permanently" + ); + } + Ok(()) + } + + /// Send an Undo(Like) activity to a single inbox. + pub async fn broadcast_undo_like_to_inbox( + &self, + liker_user_id: uuid::Uuid, + object_ap_id: url::Url, + author_inbox_url: url::Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(liker_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Reconstruct the same deterministic like ID. + let like_id = url::Url::parse(&format!( + "{}/activities/like/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", liker_user_id, object_ap_id).as_bytes(), + ) + ))?; + + let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!({ + "type": "Like", + "id": like_id.to_string(), + "actor": local_actor.ap_id.to_string(), + "object": object_ap_id.to_string(), + }), + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(undo), + &local_actor, + vec![author_inbox_url], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some Undo(Like) deliveries failed permanently" + ); + } + Ok(()) + } + + /// Resolve a `@user@domain` handle to a `DbActor` over HTTPS directly. + /// The library's `webfinger_resolve_actor` tries HTTP first in debug mode, which breaks + /// on servers that don't redirect HTTP → HTTPS. + async fn webfinger_https( + handle: &str, + data: &activitypub_federation::config::Data, + ) -> anyhow::Result { + let normalized = handle.trim_start_matches('@'); + let at = normalized + .rfind('@') + .ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?; + let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]); + let wf_url = format!( + "https://{}/.well-known/webfinger?resource=acct:{}@{}", + domain_str, user, domain_str + ); + let wf: serde_json::Value = reqwest::Client::new() + .get(&wf_url) + .header("Accept", "application/jrd+json, application/json") + .send() + .await? + .json() + .await?; + let self_href = wf["links"] + .as_array() + .and_then(|links| { + links.iter().find(|l| { + l["rel"].as_str() == Some("self") + && l["type"].as_str() == Some("application/activity+json") + }) + }) + .and_then(|l| l["href"].as_str()) + .ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))? + .to_owned(); + let self_url = url::Url::parse(&self_href)?; + let actor: DbActor = ObjectId::from(self_url) + .dereference(data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(actor) + } + + pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + + let normalized = handle.trim_start_matches('@'); + let parts: Vec<&str> = normalized.splitn(2, '@').collect(); + if parts.len() == 2 && parts[1] == data.domain { + return self.follow_local(local_user_id, parts[0], &data).await; + } + + let remote_actor: DbActor = Self::webfinger_https(handle, &data).await?; + + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let follow_id_str = follow_id.to_string(); + let follow = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: ObjectId::from(remote_actor.ap_id.clone()), + }; + let follow_with_ctx = WithContext::new_default(follow); + + let sends = SendActivityTask::prepare( + &follow_with_ctx, + &local_actor, + vec![remote_actor.inbox()], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); + } + + let domain = remote_actor.ap_id.host_str().unwrap_or(""); + let full_handle = format!("{}@{}", remote_actor.username, domain); + let remote = RemoteActor { + url: remote_actor.ap_id.to_string(), + handle: full_handle, + inbox_url: remote_actor.inbox_url.to_string(), + shared_inbox_url: remote_actor + .shared_inbox_url + .as_ref() + .map(|u| u.to_string()), + display_name: Some(remote_actor.username.clone()), + avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()), + outbox_url: Some(remote_actor.outbox_url.to_string()), + }; + data.federation_repo + .add_following(local_user_id, remote, &follow_id_str) + .await?; + + Ok(()) + } + + pub async fn unfollow( + &self, + local_user_id: uuid::Uuid, + actor_url_str: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + + if actor_url_str.starts_with(&self.base_url) { + return self + .unfollow_local(local_user_id, actor_url_str, &data) + .await; + } + + let remote = data + .federation_repo + .get_remote_actor(actor_url_str) + .await? + .ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?; + + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let remote_ap_id = Url::parse(actor_url_str)?; + let inbox = Url::parse(&remote.inbox_url)?; + + let follow_activity_id_str = data + .federation_repo + .get_follow_activity_id(local_user_id, actor_url_str) + .await?; + let follow_id = match follow_activity_id_str { + Some(id) => Url::parse(&id)?, + None => activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + }; + let follow = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: ObjectId::from(remote_ap_id), + }; + + let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let undo = UndoActivity { + id: undo_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?, + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(undo), + &local_actor, + vec![inbox], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); + } + + data.federation_repo + .remove_following(local_user_id, actor_url_str) + .await?; + + data.object_handler + .on_actor_removed(&Url::parse(actor_url_str)?) + .await?; + + Ok(()) + } + + pub async fn accept_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let remote_actor = data + .federation_repo + .get_remote_actor(remote_actor_url) + .await? + .ok_or_else(|| anyhow::anyhow!("remote actor not found"))?; + + let follow_id_str = data + .federation_repo + .get_follower_follow_activity_id(local_user_id, remote_actor_url) + .await? + .ok_or_else(|| { + anyhow::anyhow!("follow activity id not found for {}", remote_actor_url) + })?; + let follow_id = Url::parse(&follow_id_str)?; + let follow = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(Url::parse(remote_actor_url)?), + object: ObjectId::from(local_actor.ap_id.clone()), + }; + let accept = AcceptActivity { + id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: follow, + }; + + data.federation_repo + .update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted) + .await?; + + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(accept), + &local_actor, + vec![inbox.clone()], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + "failed to deliver Accept activity, but follower is marked accepted locally" + ); + } + + let target_inbox = remote_actor + .shared_inbox_url + .clone() + .unwrap_or_else(|| remote_actor.inbox_url.clone()); + self.spawn_backfill(local_user_id, target_inbox); + + Ok(()) + } + + pub async fn reject_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let remote_actor = data + .federation_repo + .get_remote_actor(remote_actor_url) + .await? + .ok_or_else(|| anyhow::anyhow!("remote actor not found"))?; + + let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let follow = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(Url::parse(remote_actor_url)?), + object: ObjectId::from(local_actor.ap_id.clone()), + }; + let reject = RejectActivity { + id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: follow, + }; + + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(reject), + &local_actor, + vec![inbox], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); + } + + data.federation_repo + .remove_follower(local_user_id, remote_actor_url) + .await?; + + Ok(()) + } + + pub async fn get_pending_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .get_pending_followers(local_user_id) + .await + } + + pub async fn get_accepted_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + let followers = data.federation_repo.get_followers(local_user_id).await?; + Ok(followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .map(|f| f.actor) + .collect()) + } + + pub async fn count_accepted_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result { + let data = self.federation_config.to_request_data(); + let followers = data.federation_repo.get_followers(local_user_id).await?; + Ok(followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .count()) + } + + pub async fn get_following( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + data.federation_repo.get_following(local_user_id).await + } + + pub async fn count_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result { + let data = self.federation_config.to_request_data(); + data.federation_repo.count_following(local_user_id).await + } + + pub async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .remove_follower(local_user_id, actor_url) + .await + } + + /// Broadcast a Delete activity to all accepted followers for a removed review. + pub async fn broadcast_delete_to_followers( + &self, + local_user_id: uuid::Uuid, + ap_id: Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { + return Ok(()); + }; + + let delete_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let delete = crate::activities::DeleteActivity { + id: delete_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!(ap_id.to_string()), + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + let delete_with_ctx = WithContext::new_default(delete); + let sends = + SendActivityTask::prepare(&delete_with_ctx, &local_actor, inboxes, &data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some delete activity deliveries failed" + ); + } + Ok(()) + } + + /// Broadcast an Add(WatchlistObject) activity to all accepted followers. + pub async fn broadcast_add_to_followers( + &self, + local_user_id: uuid::Uuid, + ap_id: Url, + object: serde_json::Value, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { + return Ok(()); + }; + + let add = crate::activities::AddActivity { + id: ap_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + let add_with_ctx = WithContext::new_default(add); + let sends = SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Add deliveries failed"); + } + Ok(()) + } + + /// Broadcast an Undo(Add) activity to all accepted followers. + pub async fn broadcast_undo_add_to_followers( + &self, + local_user_id: uuid::Uuid, + watchlist_entry_ap_id: Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { + return Ok(()); + }; + + let undo_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!({ + "type": "Add", + "id": watchlist_entry_ap_id.as_str(), + "object": { "id": watchlist_entry_ap_id.as_str() } + }), + }; + let undo_with_ctx = WithContext::new_default(undo); + let sends = SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed"); + } + Ok(()) + } + + pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> { + use activitypub_federation::traits::Object; + + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let person = local_actor + .clone() + .into_json(&data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + // Wrap with @context so Mastodon's JSON-LD processor can resolve field names. + let person_json = serde_json::to_value(WithContext::new_default(person))?; + + let update_id = Url::parse(&format!( + "{}/activities/update/{}", + self.base_url, + uuid::Uuid::new_v4() + ))?; + + let update = UpdateActivity { + id: update_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: person_json, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + + let followers = data.federation_repo.get_followers(user_id).await?; + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .collect(); + + if accepted.is_empty() { + tracing::info!(user_id = %user_id, "no accepted followers, skipping actor update broadcast"); + return Ok(()); + } + + let inboxes = collect_inboxes(&accepted); + tracing::info!( + user_id = %user_id, + follower_count = accepted.len(), + inbox_count = inboxes.len(), + inboxes = ?inboxes, + "broadcasting actor update" + ); + + let sends = SendActivityTask::prepare( + &WithContext::new_default(update), + &local_actor, + inboxes, + &data, + ) + .await?; + + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + return Err(anyhow::anyhow!( + "actor update delivery failed for {} inbox(es): {}", + failures.len(), + failures + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; ") + )); + } + tracing::info!(user_id = %user_id, "actor update broadcast complete"); + Ok(()) + } + + pub async fn block_actor( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + + data.federation_repo + .add_blocked_actor(local_user_id, actor_url) + .await?; + let _ = data + .federation_repo + .remove_follower(local_user_id, actor_url) + .await; + let _ = data + .federation_repo + .remove_following(local_user_id, actor_url) + .await; + + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await { + let block_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let block = crate::activities::BlockActivity { + id: block_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: Url::parse(actor_url)?, + }; + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(block), + &local_actor, + vec![inbox], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(actor = %actor_url, "failed to deliver Block activity"); + } + } + + Ok(()) + } + + pub async fn unblock_actor( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .remove_blocked_actor(local_user_id, actor_url) + .await + } + + pub async fn get_blocked_actors( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + let actor_urls = data + .federation_repo + .get_blocked_actors(local_user_id) + .await?; + let mut actors = Vec::new(); + for url in actor_urls { + let actor = match data.federation_repo.get_remote_actor(&url).await { + Ok(Some(a)) => a, + _ => RemoteActor { + url: url.clone(), + handle: url.clone(), + inbox_url: url.clone(), + shared_inbox_url: None, + display_name: None, + avatar_url: None, + outbox_url: None, + }, + }; + actors.push(actor); + } + Ok(actors) + } + + pub async fn add_blocked_domain( + &self, + domain: &str, + reason: Option<&str>, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .add_blocked_domain(domain, reason) + .await + } + + pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo.remove_blocked_domain(domain).await + } + + pub async fn get_blocked_domains(&self) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + data.federation_repo.get_blocked_domains().await + } + + async fn follow_local( + &self, + local_user_id: uuid::Uuid, + target_username: &str, + data: &activitypub_federation::config::Data, + ) -> anyhow::Result<()> { + let target = data + .user_repo + .find_by_username(target_username) + .await? + .ok_or_else(|| anyhow::anyhow!("user not found: {}", target_username))?; + + if target.id == local_user_id { + return Err(anyhow::anyhow!("cannot follow yourself")); + } + + let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); + let target_actor_url = crate::urls::actor_url(&self.base_url, target.id); + let target_inbox_url = format!("{}/inbox", target_actor_url); + let follow_id = activity_url(&self.base_url) + .map_err(|e| anyhow::anyhow!("{e}"))? + .to_string(); + + data.federation_repo + .add_follower( + target.id, + &follower_actor_url, + FollowerStatus::Accepted, + &follow_id, + ) + .await?; + + let target_as_remote = RemoteActor { + url: target_actor_url.to_string(), + handle: format!("{}@{}", target.username, data.domain), + inbox_url: target_inbox_url, + shared_inbox_url: None, + display_name: Some(target.username), + avatar_url: None, + outbox_url: None, + }; + data.federation_repo + .add_following(local_user_id, target_as_remote, &follow_id) + .await?; + + data.federation_repo + .update_following_status( + local_user_id, + target_actor_url.as_ref(), + FollowingStatus::Accepted, + ) + .await?; + + tracing::info!(follower = %local_user_id, followee = %target.id, "local follow"); + Ok(()) + } + + async fn unfollow_local( + &self, + local_user_id: uuid::Uuid, + target_actor_url: &str, + data: &activitypub_federation::config::Data, + ) -> anyhow::Result<()> { + let target_url = Url::parse(target_actor_url)?; + let target_user_id = crate::urls::extract_user_id_from_url(&target_url) + .ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?; + + let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); + + data.federation_repo + .remove_follower(target_user_id, &local_actor_url) + .await?; + data.federation_repo + .remove_following(local_user_id, target_actor_url) + .await?; + + tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow"); + Ok(()) + } + + pub async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS)) + .build()?; + let data = self.federation_config.to_request_data(); + let actor = url::Url::parse(actor_url)?; + + let root: serde_json::Value = client + .get(outbox_url) + .header("Accept", "application/activity+json") + .send() + .await? + .json() + .await?; + + let first = match root.get("first").and_then(|v| v.as_str()) { + Some(url) => url.to_string(), + None => { + tracing::debug!(outbox = %outbox_url, "outbox has no first page, nothing to backfill"); + return Ok(()); + } + }; + + let mut current_url = first; + let mut visited = std::collections::HashSet::new(); + + loop { + if !visited.insert(current_url.clone()) { + tracing::warn!(url = %current_url, "backfill: loop detected, stopping"); + break; + } + + let page: serde_json::Value = match client + .get(¤t_url) + .header("Accept", "application/activity+json") + .send() + .await + { + Ok(resp) => match resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::error!(error = %e, url = %current_url, "backfill: failed to parse page JSON"); + break; + } + }, + Err(e) => { + tracing::error!(error = %e, url = %current_url, "backfill: HTTP request failed"); + break; + } + }; + + if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) { + for item in items { + let activity_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if activity_type != "Create" && activity_type != "Add" { + continue; + } + let object = match item.get("object") { + Some(o) if o.is_object() => o.clone(), + _ => continue, + }; + let ap_id = match object + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| url::Url::parse(s).ok()) + { + Some(u) => u, + None => continue, + }; + if let Err(e) = data.object_handler.on_create(&ap_id, &actor, object).await { + tracing::warn!(ap_id = %ap_id, error = %e, "backfill: failed to process item, skipping"); + } + } + } + + match page.get("next").and_then(|v| v.as_str()) { + Some(next) => current_url = next.to_string(), + None => break, + } + } + + tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete"); + Ok(()) + } + + fn adapter_actor_to_domain( + a: crate::repository::RemoteActor, + ) -> domain::models::remote_actor::RemoteActor { + domain::models::remote_actor::RemoteActor { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + outbox_url: a.outbox_url, + last_fetched_at: chrono::Utc::now(), + bio: None, + banner_url: None, + also_known_as: None, + followers_url: None, + following_url: None, + attachment: vec![], + } + } + + fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) { + let config = self.federation_config.clone(); + let base_url = self.base_url.clone(); + tokio::spawn(async move { + if let Err(e) = ActivityPubService::run_backfill( + config, + base_url, + owner_user_id, + follower_inbox_url, + ) + .await + { + tracing::warn!(error = %e, "backfill: task failed"); + } + }); + } + + async fn run_backfill( + config: ApFederationConfig, + base_url: String, + owner_user_id: uuid::Uuid, + follower_inbox_url: String, + ) -> anyhow::Result<()> { + const BATCH_SIZE: usize = 20; + + let data = config.to_request_data(); + let local_actor = get_local_actor(owner_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let inbox = Url::parse(&follower_inbox_url)?; + + let mut objects = data + .object_handler + .get_local_objects_for_user(owner_user_id) + .await?; + objects.reverse(); // oldest first → chronological feed + + let total = objects.len(); + let mut success_count = 0usize; + let mut failure_count = 0usize; + + for chunk in objects.chunks(BATCH_SIZE) { + for (ap_id, object_json) in chunk { + // Use a stable Create activity ID derived from the object's ap_id + let create_id = Url::parse(&format!( + "{}/activities/create/{}", + base_url, + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes()) + ))?; + + let create = CreateActivity { + id: create_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: object_json.clone(), + to: vec![], + cc: vec![], + bto: vec![], + bcc: vec![], + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(create), + &local_actor, + vec![inbox.clone()], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if failures.is_empty() { + success_count += 1; + } else { + failure_count += 1; + } + } + tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS)).await; + } + + tracing::info!( + user_id = %owner_user_id, + follower = %follower_inbox_url, + sent = success_count, + failed = failure_count, + total = total, + "backfill complete" + ); + Ok(()) + } +} + +#[async_trait::async_trait] +impl crate::ap_ports::OutboundFederationPort for ActivityPubService { + // Actor identity (ap_id, followers_url) comes from federation config via get_local_actor. + // author_username is provided by the caller but not needed here. + async fn broadcast_create( + &self, + author_user_id: &domain::value_objects::UserId, + thought: &domain::models::thought::Thought, + _author_username: &str, + in_reply_to_url: Option<&str>, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, user_uuid) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? + else { + return Ok(()); + }; + + let (ap_id, note) = + thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + + let create = crate::activities::CreateActivity { + id: ap_id, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), + object: note, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + bto: vec![], + bcc: vec![], + }; + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(create), + &local_actor, + inboxes, + &data, + ) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Create deliveries failed"); + } + Ok(()) + } + + async fn broadcast_delete( + &self, + author_user_id: &domain::value_objects::UserId, + thought_ap_id: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let ap_id = url::Url::parse(thought_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_delete_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + // Actor identity (ap_id, followers_url) comes from federation config via get_local_actor. + // author_username is provided by the caller but not needed here. + async fn broadcast_update( + &self, + author_user_id: &domain::value_objects::UserId, + thought: &domain::models::thought::Thought, + _author_username: &str, + in_reply_to_url: Option<&str>, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let data = self.federation_config.to_request_data(); + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, user_uuid) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? + else { + return Ok(()); + }; + + let (_ap_id, note) = + thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + + let update_id = url::Url::parse(&format!( + "{}/activities/update/{}", + self.base_url, + uuid::Uuid::new_v4() + )) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let update = crate::activities::UpdateActivity { + id: update_id, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), + object: note, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(update), + &local_actor, + inboxes, + &data, + ) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Update deliveries failed"); + } + Ok(()) + } + + async fn broadcast_announce( + &self, + booster_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = booster_user_id.as_uuid(); + let ap_id = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_announce_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_undo_announce( + &self, + booster_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = booster_user_id.as_uuid(); + let ap_id = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_undo_announce_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_like( + &self, + liker_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + author_inbox_url: &str, + ) -> Result<(), domain::errors::DomainError> { + let object = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let inbox = url::Url::parse(author_inbox_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_undo_like( + &self, + liker_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + author_inbox_url: &str, + ) -> Result<(), domain::errors::DomainError> { + let object = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let inbox = url::Url::parse(author_inbox_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_actor_update( + &self, + user_id: &domain::value_objects::UserId, + ) -> Result<(), domain::errors::DomainError> { + self.broadcast_actor_update(user_id.as_uuid()) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } +} + +#[async_trait::async_trait] +impl domain::ports::FederationSchedulerPort for ActivityPubService { + async fn schedule_actor_posts_fetch( + &self, + actor_ap_url: &str, + outbox_url: &str, + ) -> Result<(), domain::errors::DomainError> { + tracing::debug!( + actor = actor_ap_url, + outbox = outbox_url, + "schedule_actor_posts_fetch: deferred" + ); + Ok(()) + } + + async fn schedule_connections_fetch( + &self, + actor_ap_url: &str, + collection_url: &str, + connection_type: &str, + page: u32, + ) -> Result<(), domain::errors::DomainError> { + tracing::debug!( + actor = actor_ap_url, + collection = collection_url, + connection_type, + page, + "schedule_connections_fetch: deferred" + ); + Ok(()) + } +} + +#[async_trait::async_trait] +impl domain::ports::FederationLookupPort for ActivityPubService { + async fn lookup_actor( + &self, + handle: &str, + ) -> Result { + use activitypub_federation::fetch::object_id::ObjectId; + + let normalized = handle.trim_start_matches('@'); + let at = normalized.rfind('@').ok_or_else(|| { + domain::errors::DomainError::InvalidInput("handle must be user@domain".into()) + })?; + let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]); + + // Fetch WebFinger over HTTPS directly — the library's webfinger_resolve_actor + // tries HTTP first in debug mode, which fails on servers without HTTP→HTTPS redirect. + let wf_url = format!( + "https://{}/.well-known/webfinger?resource=acct:{}@{}", + domain_str, user, domain_str + ); + let wf: serde_json::Value = reqwest::Client::new() + .get(&wf_url) + .header("Accept", "application/jrd+json, application/json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + let self_href = wf["links"] + .as_array() + .and_then(|links| { + links.iter().find(|l| { + l["rel"].as_str() == Some("self") + && l["type"].as_str() == Some("application/activity+json") + }) + }) + .and_then(|l| l["href"].as_str()) + .ok_or(domain::errors::DomainError::NotFound)?; + + let self_url = url::Url::parse(self_href) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + let data = self.federation_config.to_request_data(); + let actor: crate::actors::DbActor = ObjectId::from(self_url) + .dereference(&data) + .await + .map_err(|e: crate::error::Error| { + domain::errors::DomainError::ExternalService(e.to_string()) + })?; + + let domain_str = actor.ap_id.host_str().unwrap_or(""); + let full_handle = format!("{}@{}", actor.username, domain_str); + + Ok(domain::models::remote_actor::RemoteActor { + url: actor.ap_id.to_string(), + handle: full_handle, + display_name: Some(actor.username.clone()), + avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), + last_fetched_at: actor.last_refreshed_at, + bio: actor.bio.clone(), + banner_url: actor.banner_url.as_ref().map(|u| u.to_string()), + also_known_as: actor.also_known_as.clone(), + outbox_url: Some(actor.outbox_url.to_string()), + followers_url: Some(actor.followers_url.to_string()), + following_url: Some(actor.following_url.to_string()), + attachment: actor + .attachment + .iter() + .map(|f| (f.name.clone(), f.value.clone())) + .collect(), + }) + } + + async fn actor_json( + &self, + user_id: &domain::value_objects::UserId, + ) -> Result { + ActivityPubService::actor_json(self, &user_id.as_uuid().to_string()) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn followers_collection_json( + &self, + user_id: &domain::value_objects::UserId, + page: Option, + ) -> Result { + let data = self.federation_config.to_request_data(); + let uuid = user_id.as_uuid(); + let collection_id = format!("{}/users/{}/followers", self.base_url, uuid); + let total = data + .federation_repo + .count_followers(uuid) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let obj = if let Some(p) = page { + let p = p.max(1); + let offset = (p.saturating_sub(1) as usize) * crate::urls::AP_PAGE_SIZE; + let followers = data + .federation_repo + .get_followers_page(uuid, offset as u32, crate::urls::AP_PAGE_SIZE) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let has_next = offset + followers.len() < total; + let items: Vec = followers.into_iter().map(|f| f.actor.url).collect(); + let mut obj = serde_json::json!({ + "@context": crate::urls::AP_CONTEXT, + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, p), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + if has_next { + obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1)); + } + obj + } else { + serde_json::json!({ + "@context": crate::urls::AP_CONTEXT, + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }) + }; + serde_json::to_string(&obj) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn following_collection_json( + &self, + user_id: &domain::value_objects::UserId, + page: Option, + ) -> Result { + let data = self.federation_config.to_request_data(); + let uuid = user_id.as_uuid(); + let collection_id = format!("{}/users/{}/following", self.base_url, uuid); + let total = data + .federation_repo + .count_following(uuid) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let obj = if let Some(p) = page { + let p = p.max(1); + let offset = (p.saturating_sub(1) as usize) * crate::urls::AP_PAGE_SIZE; + let following = data + .federation_repo + .get_following_page(uuid, offset as u32, crate::urls::AP_PAGE_SIZE) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let has_next = offset + following.len() < total; + let items: Vec = following.into_iter().map(|a| a.url).collect(); + let mut obj = serde_json::json!({ + "@context": crate::urls::AP_CONTEXT, + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, p), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + if has_next { + obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1)); + } + obj + } else { + serde_json::json!({ + "@context": crate::urls::AP_CONTEXT, + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }) + }; + serde_json::to_string(&obj) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } +} + +#[async_trait::async_trait] +impl domain::ports::FederationFetchPort for ActivityPubService { + async fn fetch_outbox_page( + &self, + outbox_url: &str, + page: u32, + ) -> Result, domain::errors::DomainError> { + use chrono::DateTime; + + // Fetch the base outbox to find the real first-page URL. + // Mastodon uses ?page=true; other servers may use ?page=1 or a different param. + let client = reqwest::Client::new(); + let base: serde_json::Value = client + .get(outbox_url) + .header("Accept", "application/activity+json, application/ld+json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + // Prefer the `first` link from the OrderedCollection; fall back to ?page=1. + let url = base["first"] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{}?page={}", outbox_url, page)); + + let resp: serde_json::Value = client + .get(&url) + .header("Accept", "application/activity+json, application/ld+json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + let empty = vec![]; + let items = resp["orderedItems"].as_array().unwrap_or(&empty); + + let notes = items + .iter() + .filter_map(|item| { + // Items are Create activities wrapping a Note, or Notes directly + let note = if item["type"].as_str() == Some("Create") { + &item["object"] + } else if item["type"].as_str() == Some("Note") { + item + } else { + return None; + }; + + // Only public notes + let to = note["to"].as_array()?; + let is_public = to + .iter() + .any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")); + if !is_public { + return None; + } + + let published = DateTime::parse_from_rfc3339(note["published"].as_str()?) + .ok()? + .with_timezone(&chrono::Utc); + + let text = note["content"].as_str().unwrap_or("").to_string(); + let has_attachments = note["attachment"] + .as_array() + .map(|a| !a.is_empty()) + .unwrap_or(false); + + let content = if has_attachments { + let notice = + "

📎 Media attachment — not supported

"; + if text.is_empty() { + notice.to_string() + } else { + format!("{text}{notice}") + } + } else { + text + }; + + Some(domain::models::remote_note::RemoteNote { + ap_id: note["id"].as_str()?.to_string(), + content, + published, + sensitive: note["sensitive"].as_bool().unwrap_or(false), + content_warning: note["summary"].as_str().map(|s| s.to_string()), + }) + }) + .collect(); + + Ok(notes) + } + + async fn fetch_actor_urls_from_collection( + &self, + collection_url: &str, + ) -> Result, domain::errors::DomainError> { + let client = reqwest::Client::new(); + let base: serde_json::Value = client + .get(collection_url) + .header("Accept", "application/activity+json, application/ld+json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + // Base collections typically have no orderedItems — follow the `first` page link. + let page = if base["orderedItems"].is_null() { + if let Some(first_url) = base["first"].as_str() { + client + .get(first_url) + .header("Accept", "application/activity+json, application/ld+json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + } else { + base + } + } else { + base + }; + + let empty = vec![]; + let items = page["orderedItems"].as_array().unwrap_or(&empty); + Ok(items + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) + } + + async fn resolve_actor_profiles( + &self, + urls: Vec, + ) -> Vec { + use futures::future; + + async fn fetch_one( + url: String, + ) -> Option { + let resp: serde_json::Value = tokio::time::timeout( + std::time::Duration::from_secs(5), + reqwest::Client::new() + .get(&url) + .header("Accept", "application/activity+json") + .send(), + ) + .await + .ok()? + .ok()? + .json() + .await + .ok()?; + + let ap_url = resp["id"].as_str()?.to_string(); + let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string(); + let domain_str = url::Url::parse(&ap_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + let handle = format!("{}@{}", preferred_username, domain_str); + let display_name = resp["name"].as_str().map(|s| s.to_string()); + let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string()); + + Some( + domain::models::actor_connection_summary::ActorConnectionSummary { + url: ap_url, + handle, + display_name, + avatar_url, + }, + ) + } + + let futs: Vec<_> = urls.into_iter().map(fetch_one).collect(); + let results = future::join_all(futs).await; + + results + .into_iter() + .filter_map(|r| { + if r.is_none() { + tracing::warn!("failed to resolve actor profile (timeout or parse error)"); + } + r + }) + .collect() + } +} + +#[async_trait::async_trait] +impl domain::ports::FederationFollowPort for ActivityPubService { + async fn follow_remote( + &self, + local_user_id: &domain::value_objects::UserId, + handle: &str, + ) -> Result<(), domain::errors::DomainError> { + self.follow(local_user_id.as_uuid(), handle) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn unfollow_remote( + &self, + local_user_id: &domain::value_objects::UserId, + handle: &str, + ) -> Result<(), domain::errors::DomainError> { + let data = self.federation_config.to_request_data(); + let remote_actor: DbActor = Self::webfinger_https(handle, &data) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let actor_url = remote_actor.ap_id.to_string(); + self.unfollow(local_user_id.as_uuid(), &actor_url) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn get_remote_following( + &self, + user_id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.get_following(user_id.as_uuid()) + .await + .map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect()) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } +} + +#[async_trait::async_trait] +impl domain::ports::FederationFollowRequestPort for ActivityPubService { + async fn get_pending_followers( + &self, + user_id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.get_pending_followers(user_id.as_uuid()) + .await + .map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect()) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn accept_follow_request( + &self, + user_id: &domain::value_objects::UserId, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { + self.accept_follower(user_id.as_uuid(), actor_url) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn reject_follow_request( + &self, + user_id: &domain::value_objects::UserId, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { + self.reject_follower(user_id.as_uuid(), actor_url) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn get_remote_followers( + &self, + user_id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.get_accepted_followers(user_id.as_uuid()) + .await + .map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect()) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn remove_remote_follower( + &self, + user_id: &domain::value_objects::UserId, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { + self.remove_follower(user_id.as_uuid(), actor_url) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } +} + +#[cfg(test)] +#[path = "tests/service.rs"] +mod tests; diff --git a/crates/adapters/activitypub-base/src/tests/actors.rs b/crates/adapters/activitypub-base/src/tests/actors.rs new file mode 100644 index 0000000..7f510c4 --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/actors.rs @@ -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::() + .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" + ); +} diff --git a/crates/adapters/activitypub-base/src/tests/nodeinfo.rs b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs new file mode 100644 index 0000000..898e1bf --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs @@ -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); +} diff --git a/crates/adapters/activitypub-base/src/tests/service.rs b/crates/adapters/activitypub-base/src/tests/service.rs new file mode 100644 index 0000000..0f8b034 --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/service.rs @@ -0,0 +1,75 @@ +fn _assert_impl_federation_lookup_port() +where + crate::service::ActivityPubService: domain::ports::FederationLookupPort, +{ +} + +fn _assert_impl_federation_follow_port() +where + crate::service::ActivityPubService: domain::ports::FederationFollowPort, +{ +} + +fn _assert_impl_federation_follow_request_port() +where + crate::service::ActivityPubService: domain::ports::FederationFollowRequestPort, +{ +} + +fn _assert_impl_federation_fetch_port() +where + crate::service::ActivityPubService: domain::ports::FederationFetchPort, +{ +} + +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"); +} diff --git a/crates/adapters/activitypub-base/src/urls.rs b/crates/adapters/activitypub-base/src/urls.rs new file mode 100644 index 0000000..36bf9c8 --- /dev/null +++ b/crates/adapters/activitypub-base/src/urls.rs @@ -0,0 +1,33 @@ +use url::Url; + +use crate::error::Error; + +pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public"; +pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; +pub const AP_PAGE_SIZE: usize = 20; + +pub fn extract_user_id_from_url(url: &Url) -> Option { + 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::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 { + url.path() + .strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .map(|s| s.to_string()) +} diff --git a/crates/adapters/activitypub-base/src/user.rs b/crates/adapters/activitypub-base/src/user.rs new file mode 100644 index 0000000..a99092b --- /dev/null +++ b/crates/adapters/activitypub-base/src/user.rs @@ -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, + pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub profile_url: Option, + pub attachment: Vec, +} + +#[async_trait] +pub trait ApUserRepository: Send + Sync { + async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result>; + async fn find_by_username(&self, username: &str) -> anyhow::Result>; + async fn count_users(&self) -> anyhow::Result; +} diff --git a/crates/adapters/activitypub-base/src/webfinger.rs b/crates/adapters/activitypub-base/src/webfinger.rs new file mode 100644 index 0000000..8754287 --- /dev/null +++ b/crates/adapters/activitypub-base/src/webfinger.rs @@ -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, + data: Data, +) -> Result { + 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()) +} diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml new file mode 100644 index 0000000..bf8aa99 --- /dev/null +++ b/crates/adapters/activitypub/Cargo.toml @@ -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 } diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs new file mode 100644 index 0000000..a2635bb --- /dev/null +++ b/crates/adapters/activitypub/src/handler.rs @@ -0,0 +1,408 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; + +const USERS_PATH_PREFIX: &str = "/users/"; +const THOUGHTS_PATH_PREFIX: &str = "/thoughts/"; +use chrono::{DateTime, Utc}; +use std::sync::Arc; +use url::Url; + +use crate::note::ThoughtNote; +use crate::urls::ThoughtsUrls; +use activitypub_base::{ActivityPubRepository, ApObjectHandler}; +use domain::ports::{EventPublisher, TagRepository}; +use domain::value_objects::UserId; + +pub struct ThoughtsObjectHandler { + repo: Arc, + urls: ThoughtsUrls, + event_publisher: Option>, + tag_repo: Arc, +} + +impl ThoughtsObjectHandler { + pub fn new( + repo: Arc, + base_url: &str, + event_publisher: Option>, + tag_repo: Arc, + ) -> Self { + Self { + repo, + urls: ThoughtsUrls::new(base_url), + event_publisher, + tag_repo, + } + } +} + +#[async_trait] +impl ApObjectHandler for ThoughtsObjectHandler { + async fn get_local_objects_for_user( + &self, + user_id: uuid::Uuid, + ) -> Result> { + 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>, + limit: usize, + ) -> Result)>> { + 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.as_str()) + .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" + }; + + let thought_id = self.repo + .accept_note( + ap_id.as_str(), + &author_id, + ¬e.content, + note.published, + note.sensitive, + note.summary, + visibility, + note.in_reply_to.as_ref().map(|u| u.as_str()), + ) + .await + .map_err(|e| anyhow!("{e}"))?; + + // Extract and index hashtags from the AP tag array. + let hashtag_names: Vec = note + .tag + .iter() + .filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag")) + .filter_map(|t| t.get("name").and_then(|v| v.as_str())) + .map(|name| name.trim_start_matches('#').to_lowercase()) + .filter(|name| !name.is_empty()) + .collect(); + + for name in hashtag_names { + if let Ok(tag) = self.tag_repo.find_or_create(&name).await { + let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await; + } + } + + // Fire mention notifications for local @mentions in the note's tag array. + let base_url = url::Url::parse(&self.urls.base_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(); + + for tag in ¬e.tag { + if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") { + continue; + } + let href = match tag.get("href").and_then(|h| h.as_str()) { + Some(h) => h, + None => continue, + }; + let href_url = match url::Url::parse(href) { + Ok(u) => u, + Err(_) => continue, + }; + if href_url.host_str().unwrap_or("") != base_url { + continue; + } + let user_uuid = href_url + .path() + .strip_prefix(USERS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + if let Some(uuid) = user_uuid { + self.on_mention(ap_id, uuid, actor_url) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process mention notification"); + }); + } + } + + Ok(()) + } + + async fn on_update( + &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.as_str(), ¬e.content) + .await + .map_err(|e| anyhow!("{e}")) + } + + async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { + self.repo + .retract_note(ap_id.as_str()) + .await + .map_err(|e| anyhow!("{e}")) + } + + async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { + self.repo + .retract_actor_notes(actor_url.as_str()) + .await + .map_err(|e| anyhow!("{e}")) + } + + async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> { + let thought_uuid = object_url + .path() + .strip_prefix(THOUGHTS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + let thought_uuid = match thought_uuid { + Some(u) => u, + None => { + tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping"); + return Ok(()); + } + }; + + let actor_user_id = self + .repo + .find_remote_actor_id(actor_url.as_str()) + .await + .map_err(|e| anyhow!("{e}"))?; + + let actor_user_id = match actor_user_id { + Some(id) => id, + None => { + tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification"); + return Ok(()); + } + }; + + if let Some(ep) = &self.event_publisher { + let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid); + let like_id = domain::value_objects::LikeId::new(); + ep.publish(&domain::events::DomainEvent::LikeAdded { + like_id, + user_id: actor_user_id, + thought_id, + }) + .await + .map_err(|e| anyhow!("{e}"))?; + } + + Ok(()) + } + + async fn on_unlike(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> { + let thought_uuid = object_url + .path() + .strip_prefix(THOUGHTS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + let thought_uuid = match thought_uuid { + Some(u) => u, + None => { + tracing::debug!(object = %object_url, "on_unlike: not a local thought URL, skipping"); + return Ok(()); + } + }; + + let actor_user_id = self + .repo + .find_remote_actor_id(actor_url.as_str()) + .await + .map_err(|e| anyhow!("{e}"))?; + + let actor_user_id = match actor_user_id { + Some(id) => id, + None => { + tracing::debug!(actor = %actor_url, "on_unlike: remote actor not interned, skipping"); + return Ok(()); + } + }; + + if let Some(ep) = &self.event_publisher { + ep.publish(&domain::events::DomainEvent::LikeRemoved { + user_id: actor_user_id, + thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid), + }) + .await + .map_err(|e| anyhow!("{e}"))?; + } + + Ok(()) + } + + async fn on_mention( + &self, + thought_ap_id: &url::Url, + mentioned_user_uuid: uuid::Uuid, + actor_url: &url::Url, + ) -> anyhow::Result<()> { + let author_user_id = match self + .repo + .find_remote_actor_id(actor_url.as_str()) + .await + .map_err(|e| anyhow!("{e}"))? + { + Some(id) => id, + None => return Ok(()), + }; + + let thought_uuid = thought_ap_id + .path() + .strip_prefix(THOUGHTS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + let thought_uuid = match thought_uuid { + Some(u) => u, + None => return Ok(()), + }; + + if let Some(ep) = &self.event_publisher { + ep.publish(&domain::events::DomainEvent::MentionReceived { + thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid), + mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid), + author_user_id, + }) + .await + .map_err(|e| anyhow!("{e}"))?; + } + + Ok(()) + } + + async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> { + let thought_uuid = object_url + .path() + .strip_prefix(THOUGHTS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + let thought_uuid = match thought_uuid { + Some(u) => u, + None => return Ok(()), + }; + + let actor_user_id = self + .repo + .find_remote_actor_id(actor_url.as_str()) + .await + .map_err(|e| anyhow!("{e}"))?; + + let actor_user_id = match actor_user_id { + Some(id) => id, + None => return Ok(()), + }; + + if let Some(ep) = &self.event_publisher { + let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid); + let boost_id = domain::value_objects::BoostId::new(); + ep.publish(&domain::events::DomainEvent::BoostAdded { + boost_id, + user_id: actor_user_id, + thought_id, + }) + .await + .map_err(|e| anyhow!("{e}"))?; + } + + Ok(()) + } + + async fn count_local_posts(&self) -> Result { + self.repo + .count_local_notes() + .await + .map_err(|e| anyhow!("{e}")) + } +} diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs new file mode 100644 index 0000000..5f8d6bd --- /dev/null +++ b/crates/adapters/activitypub/src/lib.rs @@ -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; diff --git a/crates/adapters/activitypub/src/note.rs b/crates/adapters/activitypub/src/note.rs new file mode 100644 index 0000000..9d2941f --- /dev/null +++ b/crates/adapters/activitypub/src/note.rs @@ -0,0 +1,81 @@ +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, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub cc: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + pub sensitive: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub tag: Vec, +} + +impl ThoughtNote { + #[allow(clippy::too_many_arguments)] + pub fn new_public( + id: Url, + actor_url: Url, + content: String, + published: DateTime, + in_reply_to: Option, + sensitive: bool, + summary: Option, + 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, + tag: Vec::new(), + } + } +} + +#[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\"")); + } +} diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs new file mode 100644 index 0000000..f15f95a --- /dev/null +++ b/crates/adapters/activitypub/src/urls.rs @@ -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/")); + } +} diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml new file mode 100644 index 0000000..eb10094 --- /dev/null +++ b/crates/adapters/auth/Cargo.toml @@ -0,0 +1,19 @@ +[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" +bcrypt = "0.15" +rand = "0.8" +sha2 = "0.10" +hex = "0.4" diff --git a/crates/adapters/auth/src/api_key_service.rs b/crates/adapters/auth/src/api_key_service.rs new file mode 100644 index 0000000..7622396 --- /dev/null +++ b/crates/adapters/auth/src/api_key_service.rs @@ -0,0 +1,89 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{ApiKeyRepository, ApiKeyService}, + value_objects::UserId, +}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +pub struct ApiKeyServiceImpl { + repo: Arc, +} + +impl ApiKeyServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } + + fn hash(raw: &str) -> String { + hex::encode(Sha256::digest(raw.as_bytes())) + } +} + +#[async_trait] +impl ApiKeyService for ApiKeyServiceImpl { + async fn validate_key(&self, raw_key: &str) -> Result, DomainError> { + let hash = Self::hash(raw_key); + Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use chrono::Utc; + use domain::{ + errors::DomainError, + models::api_key::ApiKey, + ports::ApiKeyRepository, + value_objects::{ApiKeyId, UserId}, + }; + use std::sync::{Arc, Mutex}; + + struct FakeApiKeyRepo(Mutex>); + + #[async_trait] + impl ApiKeyRepository for FakeApiKeyRepo { + async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { + self.0.lock().unwrap().push(key.clone()); + Ok(()) + } + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) + } + async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> { + Ok(()) + } + } + + #[tokio::test] + async fn validate_known_key_returns_user_id() { + let uid = UserId::new(); + let raw = "super-secret-key"; + let hash = ApiKeyServiceImpl::hash(raw); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: uid.clone(), + key_hash: hash, + name: "test".into(), + created_at: Utc::now(), + }; + let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key]))); + let svc = ApiKeyServiceImpl::new(repo); + let result = svc.validate_key(raw).await.unwrap(); + assert_eq!(result.unwrap().as_uuid(), uid.as_uuid()); + } + + #[tokio::test] + async fn validate_unknown_key_returns_none() { + let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![]))); + let svc = ApiKeyServiceImpl::new(repo); + let result = svc.validate_key("unknown-key").await.unwrap(); + assert!(result.is_none()); + } +} diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs new file mode 100644 index 0000000..c4e240a --- /dev/null +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,123 @@ +mod api_key_service; + +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}; + +pub use api_key_service::ApiKeyServiceImpl; + +#[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 { + 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 { + let data = decode::( + 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 { + 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 { + if hash.0.starts_with("$2") { + return bcrypt::verify(plain, &hash.0) + .map_err(|e| DomainError::Internal(e.to_string())); + } + use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; + let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; + 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("a-secret-that-is-at-least-32-bytes!!".into(), 3600); + let id = UserId::new(); + let tok = svc.generate_token(&id).unwrap(); + let parsed = svc.validate_token(&tok.token).unwrap(); + assert_eq!(parsed.as_uuid(), id.as_uuid()); + } + + #[test] + fn invalid_token_returns_unauthorized() { + let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); + let err = svc.validate_token("not.a.token").unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized)); + } + + #[tokio::test] + async fn hash_and_verify() { + let hasher = Argon2PasswordHasher; + let hash = hasher.hash("mypassword").await.unwrap(); + assert!(hasher.verify("mypassword", &hash).await.unwrap()); + assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); + } +} diff --git a/crates/adapters/event-payload/Cargo.toml b/crates/adapters/event-payload/Cargo.toml new file mode 100644 index 0000000..dbf32c5 --- /dev/null +++ b/crates/adapters/event-payload/Cargo.toml @@ -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 } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs new file mode 100644 index 0000000..f8c6c49 --- /dev/null +++ b/crates/adapters/event-payload/src/lib.rs @@ -0,0 +1,446 @@ +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, + }, + 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, + }, + ProfileUpdated { + user_id: String, + }, + MentionReceived { + thought_id: String, + mentioned_user_id: String, + author_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", + Self::ProfileUpdated { .. } => "users.profile_updated", + Self::MentionReceived { .. } => "mentions.received", + } + } +} + +// ── 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(), + }, + DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated { + user_id: user_id.to_string(), + }, + DomainEvent::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => Self::MentionReceived { + thought_id: thought_id.to_string(), + mentioned_user_id: mentioned_user_id.to_string(), + author_user_id: author_user_id.to_string(), + }, + } + } +} + +// ── EventPayload → DomainEvent ───────────────────────────────────────────── + +fn parse_uuid(s: &str, field: &str) -> Result { + uuid::Uuid::parse_str(s) + .map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}"))) +} + +impl TryFrom for DomainEvent { + type Error = DomainError; + + fn try_from(p: EventPayload) -> Result { + 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")?), + }, + EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + }, + EventPayload::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => DomainEvent::MentionReceived { + thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), + mentioned_user_id: UserId::from_uuid(parse_uuid( + &mentioned_user_id, + "mentioned_user_id", + )?), + author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?), + }, + }) + } +} + +#[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(), + }, + EventPayload::UserUnblocked { + blocker_id: "a".into(), + blocked_id: "b".into(), + }, + EventPayload::UserRegistered { + user_id: "a".into(), + }, + ]; + let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); + subjects.sort(); + subjects.dedup(); + assert_eq!( + subjects.len(), + samples.len(), + "each event must have a unique subject" + ); + } +} diff --git a/crates/adapters/event-transport/Cargo.toml b/crates/adapters/event-transport/Cargo.toml new file mode 100644 index 0000000..e7e7c38 --- /dev/null +++ b/crates/adapters/event-transport/Cargo.toml @@ -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"] } diff --git a/crates/adapters/event-transport/src/lib.rs b/crates/adapters/event-transport/src/lib.rs new file mode 100644 index 0000000..901b810 --- /dev/null +++ b/crates/adapters/event-transport/src/lib.rs @@ -0,0 +1,236 @@ +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 { + transport: T, +} + +impl EventPublisherAdapter { + pub fn new(transport: T) -> Self { + Self { transport } + } +} + +#[async_trait] +impl EventPublisher for EventPublisherAdapter { + 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, + pub delivery_count: u64, + pub ack: Box, + pub nack: Box, +} + +/// Abstraction over any subscribe/consume backend. +pub trait MessageSource: Send + Sync { + fn messages(&self) -> BoxStream<'_, Result>; +} + +/// Deserializes raw transport messages into domain `EventEnvelope`s. +/// Invalid or unknown messages are skipped with a warning — stream continues. +pub struct EventConsumerAdapter { + source: S, +} + +impl EventConsumerAdapter { + pub fn new(source: S) -> Self { + Self { source } + } +} + +impl EventConsumer for EventConsumerAdapter { + fn consume(&self) -> BoxStream<'_, Result> { + 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::(&msg.payload) { + Ok(p) => p, + Err(e) => { + tracing::warn!("failed to deserialize event payload — acking to prevent orphan: {e}"); + (msg.ack)(); + return None; + } + }; + let event = match DomainEvent::try_from(payload) { + Ok(e) => e, + Err(e) => { + tracing::warn!("unknown or malformed event type — acking to prevent orphan: {e}"); + (msg.ack)(); + return None; + } + }; + Some(Ok(EventEnvelope { + event, + delivery_count: msg.delivery_count, + ack: msg.ack, + nack: msg.nack, + })) + } + } + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use domain::value_objects::{ThoughtId, UserId}; + use std::sync::{Arc, Mutex}; + + struct SpyTransport { + calls: Arc)>>>, + } + impl SpyTransport { + fn new() -> (Self, Arc)>>>) { + 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, + } + #[async_trait::async_trait] + impl MessageSource for OneMessageSource { + fn messages(&self) -> futures::stream::BoxStream<'_, Result> { + let msg = RawMessage { + subject: "thoughts.created".to_string(), + payload: self.bytes.clone(), + delivery_count: 1, + ack: Box::new(|| {}), + nack: Box::new(|| {}), + }; + Box::pin(futures::stream::once(async { Ok(msg) })) + } + } + + let adapter = EventConsumerAdapter::new(OneMessageSource { bytes }); + let mut stream = adapter.consume(); + let envelope = stream.next().await.unwrap().unwrap(); + assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. })); + } + + #[tokio::test] + async fn consumer_adapter_skips_invalid_payloads() { + use futures::StreamExt; + + struct BadMessageSource; + #[async_trait::async_trait] + impl MessageSource for BadMessageSource { + fn messages(&self) -> futures::stream::BoxStream<'_, Result> { + let msg = RawMessage { + subject: "bad".to_string(), + payload: b"not valid json".to_vec(), + delivery_count: 1, + ack: Box::new(|| {}), + nack: Box::new(|| {}), + }; + Box::pin(futures::stream::once(async { Ok(msg) })) + } + } + + let adapter = EventConsumerAdapter::new(BadMessageSource); + let mut stream = adapter.consume(); + assert!(stream.next().await.is_none()); + } +} diff --git a/crates/adapters/nats/Cargo.toml b/crates/adapters/nats/Cargo.toml new file mode 100644 index 0000000..f9151aa --- /dev/null +++ b/crates/adapters/nats/Cargo.toml @@ -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 } diff --git a/crates/adapters/nats/src/lib.rs b/crates/adapters/nats/src/lib.rs new file mode 100644 index 0000000..2d047c3 --- /dev/null +++ b/crates/adapters/nats/src/lib.rs @@ -0,0 +1,285 @@ +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; + +const STREAM_NAME: &str = "THOUGHTS_EVENTS"; +const STREAM_SUBJECT: &str = "thoughts-events.>"; +const CONSUMER_NAME: &str = "worker"; +const MAX_MESSAGES: i64 = 100_000; + +/// Maximum NATS delivery attempts before a message is considered exhausted. +pub const CONSUMER_MAX_DELIVER: i64 = 5; +/// How long NATS waits for an ack before redelivering. +const CONSUMER_ACK_WAIT_SECS: u64 = 30; +/// Timeout for spawned ack/nack async tasks. +const ACK_TASK_TIMEOUT_SECS: u64 = 5; + +fn stream_config() -> StreamConfig { + StreamConfig { + name: STREAM_NAME.to_string(), + subjects: vec![STREAM_SUBJECT.to_string()], + max_messages: MAX_MESSAGES, + ..Default::default() + } +} + +/// Ensure the JetStream stream exists with the current config. +/// If an incompatible stream exists (e.g. wrong retention policy), deletes and recreates it. +pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> { + let js = jetstream::new(client.clone()); + + // Happy path: stream exists and config is compatible. + if js.update_stream(stream_config()).await.is_ok() { + tracing::info!(subject = STREAM_SUBJECT, "JetStream stream updated"); + return Ok(()); + } + + // Update failed — retention policy mismatch or other incompatibility. + // Delete the old stream and recreate with current config. + tracing::warn!( + "JetStream stream update failed (incompatible config), deleting '{STREAM_NAME}' and recreating" + ); + let _ = js.delete_stream(STREAM_NAME).await; + + js.create_stream(stream_config()) + .await + .map(|_| ()) + .map_err(|e| DomainError::Internal(format!("JetStream stream create failed: {e}"))) +} + +// ── NatsTransport — JetStream publish ────────────────────────────────────── + +pub struct NatsTransport { + jetstream: jetstream::Context, +} + +impl NatsTransport { + pub fn new(client: async_nats::Client) -> Self { + Self { + jetstream: jetstream::new(client), + } + } +} + +#[async_trait] +impl Transport for NatsTransport { + async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { + // Prefix all subjects so they land inside the stream's subject filter. + let full_subject = format!("thoughts-events.{subject}"); + self.jetstream + .publish(full_subject, bytes.to_vec().into()) + .await + .map_err(|e| DomainError::Internal(e.to_string()))? + .await // wait for server ack — confirms message is durably stored + .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> { + use futures::stream; + use tokio::sync::{mpsc, Mutex as TokioMutex}; + + let js = self.jetstream.clone(); + let (tx, rx) = mpsc::channel::>(128); + + // Spawn the consumer loop in the background. + // Pull consumer: worker explicitly fetches from NATS rather than NATS pushing. + tokio::spawn(async move { + let stream = match js.get_stream(STREAM_NAME).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await; + return; + } + }; + + // Delete any existing push consumer with this name — can't reuse as pull. + // No-op if it doesn't exist or is already a pull consumer. + if let Ok(info) = stream.consumer_info(CONSUMER_NAME).await { + if info.config.deliver_subject.is_some() { + tracing::info!( + "deleting old push consumer '{CONSUMER_NAME}', replacing with pull" + ); + let _ = stream.delete_consumer(CONSUMER_NAME).await; + } + } + + let consumer = match stream + .get_or_create_consumer( + CONSUMER_NAME, + jetstream::consumer::pull::Config { + durable_name: Some(CONSUMER_NAME.to_string()), + deliver_policy: jetstream::consumer::DeliverPolicy::New, + ack_policy: jetstream::consumer::AckPolicy::Explicit, + ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS), + max_deliver: CONSUMER_MAX_DELIVER, + // No filter_subject — consume everything from the stream. + // filter_subject matching the stream's own wildcard can be + // inconsistent across NATS server versions. + ..Default::default() + }, + ) + .await + { + Ok(c) => c, + Err(e) => { + let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await; + return; + } + }; + + tracing::info!("NATS pull consumer ready"); + + loop { + // consumer.messages() uses long-poll (no no_wait flag) — NATS holds the + // request open and delivers messages as they arrive. + // fetch() in async-nats 0.48 defaults to no_wait:true which returns + // immediately when the queue is empty, so we avoid it here. + let mut messages = match consumer.messages().await { + Ok(m) => m, + Err(e) => { + tracing::error!("NATS messages() failed: {e}"); + let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await; + return; + } + }; + + use futures::StreamExt; + while let Some(result) = messages.next().await { + let msg = match result { + Ok(m) => m, + Err(e) => { + tracing::warn!("NATS message error: {e}"); + continue; + } + }; + + let subject = msg.subject.to_string(); + let payload = msg.payload.to_vec(); + let delivery_count = msg + .info() + .map(|info| info.delivered.max(0) as u64) + .unwrap_or(1); + let msg = Arc::new(msg); + let msg_nack = Arc::clone(&msg); + + let raw = RawMessage { + subject, + payload, + delivery_count, + ack: Box::new(move || { + let m = Arc::clone(&msg); + tokio::spawn(async move { + let result = tokio::time::timeout( + std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS), + m.ack(), + ) + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"), + Err(_) => tracing::warn!( + "NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s" + ), + } + }); + }), + nack: Box::new(move || { + let m = Arc::clone(&msg_nack); + tokio::spawn(async move { + let result = tokio::time::timeout( + std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS), + m.ack_with(AckKind::Nak(None)), + ) + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"), + Err(_) => tracing::warn!( + "NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s" + ), + } + }); + }), + }; + + if tx.send(Ok(raw)).await.is_err() { + return; // receiver dropped — worker shutting down + } + } + // messages() stream ended (e.g. fetch timeout) — loop and restart + } + }); + + // Bridge the channel receiver into a BoxStream. + let rx = Arc::new(TokioMutex::new(rx)); + Box::pin(stream::unfold(rx, |rx| async move { + let item = rx.lock().await.recv().await?; + Some((item, rx)) + })) + } +} + +#[cfg(test)] +mod tests { + 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"); + } + } +} diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml new file mode 100644 index 0000000..55ab7a2 --- /dev/null +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -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"] } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs new file mode 100644 index 0000000..b1c3d5e --- /dev/null +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -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, + display_name: Option, + avatar_url: Option, + outbox_url: Option, +) -> 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> { + 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> { + #[derive(sqlx::FromRow)] + struct Row { + remote_actor_url: String, + status: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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'" + ).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> { + #[derive(sqlx::FromRow)] + struct Row { + remote_actor_url: String, + status: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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 { + 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> { + #[derive(sqlx::FromRow)] + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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> { + 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> { + #[derive(sqlx::FromRow)] + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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> { + #[derive(sqlx::FromRow)] + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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 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| + 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 { + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM federation_following 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 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> { + 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> { + #[derive(sqlx::FromRow)] + struct Row { + url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } + 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> { + #[derive(sqlx::FromRow)] + struct Row { + public_key: Option, + private_key: Option, + } + 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, + ) -> 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 { + 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> { + #[derive(sqlx::FromRow)] + struct Row { + domain: String, + reason: Option, + blocked_at: DateTime, + } + 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 { + 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> { + 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 { + 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, + avatar_url: Option, + ) -> 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> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + bio: Option, + avatar_url: Option, + } + 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> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + bio: Option, + avatar_url: Option, + } + 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 { + 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) + } +} diff --git a/crates/adapters/postgres-search/Cargo.toml b/crates/adapters/postgres-search/Cargo.toml new file mode 100644 index 0000000..4d4c0bb --- /dev/null +++ b/crates/adapters/postgres-search/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "postgres-search" +version = "0.1.0" +edition = "2021" + +[dependencies] +domain = { workspace = true } +postgres = { 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 } diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs new file mode 100644 index 0000000..7015881 --- /dev/null +++ b/crates/adapters/postgres-search/src/lib.rs @@ -0,0 +1,372 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use domain::{ + errors::DomainError, + models::{ + feed::{FeedEntry, PageParams, Paginated}, + thought::{Thought, Visibility}, + user::User, + }, + ports::SearchPort, + value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, +}; +use postgres::user::{UserRow, USER_SELECT}; +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, + visibility: String, + content_warning: Option, + sensitive: bool, + t_local: bool, + thought_created_at: DateTime, + updated_at: Option>, + author_id: uuid::Uuid, + username: String, + email: String, + password_hash: String, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + author_local: bool, + author_created_at: DateTime, + author_updated_at: DateTime, + like_count: i64, + boost_count: i64, + reply_count: i64, + liked_by_viewer: bool, + boosted_by_viewer: bool, +} + +fn feed_select(viewer: Option) -> String { + let viewer_checks = match viewer { + Some(uid) => format!( + "EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\ + EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer" + ), + None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), + }; + format!( + "\n SELECT\n\ + t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ + t.in_reply_to_id,\n\ + t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ + t.created_at AS thought_created_at, t.updated_at,\n\ + u.id AS author_id, u.username, u.email, u.password_hash,\n\ + u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ + u.local AS author_local,\n\ + u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\ + (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\ + (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\ + (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\ + {viewer_checks}\n\ + FROM thoughts t JOIN users u ON u.id=t.user_id" + ) +} + +fn row_to_entry(r: FeedRow, viewer: Option) -> Result { + let thought = Thought { + id: ThoughtId::from_uuid(r.thought_id), + user_id: UserId::from_uuid(r.t_user_id), + content: Content::new_remote(r.content), + in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), + visibility: Visibility::from_db_str(&r.visibility)?, + content_warning: r.content_warning, + sensitive: r.sensitive, + local: r.t_local, + created_at: r.thought_created_at, + updated_at: r.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, + created_at: r.author_created_at, + updated_at: r.author_updated_at, + }; + Ok(FeedEntry { + thought, + author, + stats: domain::models::feed::EngagementStats { + like_count: r.like_count, + boost_count: r.boost_count, + reply_count: r.reply_count, + }, + viewer: viewer.map(|_| domain::models::feed::ViewerContext { + liked: r.liked_by_viewer, + boosted: r.boosted_by_viewer, + }), + }) +} + +#[async_trait] +impl SearchPort for PgSearchRepository { + async fn search_thoughts( + &self, + query: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { + let viewer = viewer_id.map(|v| v.as_uuid()); + let select = feed_select(viewer); + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM thoughts t + WHERE t.content % $1 AND t.visibility='public'", + ) + .bind(query) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + let sql = format!( + "{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(|r| row_to_entry(r, viewer)) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + async fn search_users( + &self, + query: &str, + page: &PageParams, + ) -> Result, 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, UserWriter}, + 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); + } + + #[sqlx::test(migrations = "../postgres/migrations")] + async fn search_thoughts_viewer_context(pool: sqlx::PgPool) { + use domain::models::social::Like; + use domain::ports::{LikeRepository, UserWriter}; + use domain::value_objects::LikeId; + use postgres::{like::PgLikeRepository, user::PgUserRepository}; + + let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; + + // alice likes her own thought + let like_repo = PgLikeRepository::new(pool.clone()); + like_repo + .save(&Like { + id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: chrono::Utc::now(), + }) + .await + .unwrap(); + + let repo = PgSearchRepository::new(pool); + + // with viewer — should see liked = true + let authed = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + Some(&alice.id), + ) + .await + .unwrap(); + assert_eq!(authed.items.len(), 1); + let ctx = authed.items[0] + .viewer + .as_ref() + .expect("viewer context present"); + assert!(ctx.liked, "alice should see the thought as liked"); + assert!(!ctx.boosted); + + // without viewer — viewer should be None + let anon = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(anon.items.len(), 1); + assert!( + anon.items[0].viewer.is_none(), + "anonymous request has no viewer context" + ); + } +} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml new file mode 100644 index 0000000..568951a --- /dev/null +++ b/crates/adapters/postgres/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "postgres" +version = "0.1.0" +edition = "2021" + +[dependencies] +domain = { workspace = true } +activitypub-base = { workspace = true } +event-payload = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +serde_json = { 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"] } diff --git a/crates/adapters/postgres/migrations/001_initial_schema.sql b/crates/adapters/postgres/migrations/001_initial_schema.sql new file mode 100644 index 0000000..8fb8993 --- /dev/null +++ b/crates/adapters/postgres/migrations/001_initial_schema.sql @@ -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() +); diff --git a/crates/adapters/postgres/migrations/002_federation_columns.sql b/crates/adapters/postgres/migrations/002_federation_columns.sql new file mode 100644 index 0000000..f5f0ece --- /dev/null +++ b/crates/adapters/postgres/migrations/002_federation_columns.sql @@ -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(); diff --git a/crates/adapters/postgres/migrations/003_new_tables.sql b/crates/adapters/postgres/migrations/003_new_tables.sql new file mode 100644 index 0000000..c5e4c8e --- /dev/null +++ b/crates/adapters/postgres/migrations/003_new_tables.sql @@ -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); diff --git a/crates/adapters/postgres/migrations/004_search_indexes.sql b/crates/adapters/postgres/migrations/004_search_indexes.sql new file mode 100644 index 0000000..a524b56 --- /dev/null +++ b/crates/adapters/postgres/migrations/004_search_indexes.sql @@ -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; diff --git a/crates/adapters/postgres/migrations/005_federation_tables.sql b/crates/adapters/postgres/migrations/005_federation_tables.sql new file mode 100644 index 0000000..3d4a703 --- /dev/null +++ b/crates/adapters/postgres/migrations/005_federation_tables.sql @@ -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); diff --git a/crates/adapters/postgres/migrations/006_remote_actor_connections.sql b/crates/adapters/postgres/migrations/006_remote_actor_connections.sql new file mode 100644 index 0000000..36edda7 --- /dev/null +++ b/crates/adapters/postgres/migrations/006_remote_actor_connections.sql @@ -0,0 +1,13 @@ +CREATE TABLE remote_actor_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_url TEXT NOT NULL, + connection_type TEXT NOT NULL, + page INT NOT NULL, + connected_actor_url TEXT NOT NULL, + connected_handle TEXT NOT NULL, + connected_display_name TEXT, + connected_avatar_url TEXT, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(actor_url, connection_type, page, connected_actor_url) +); +CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at); diff --git a/crates/adapters/postgres/migrations/007_content_text.sql b/crates/adapters/postgres/migrations/007_content_text.sql new file mode 100644 index 0000000..ad6e93c --- /dev/null +++ b/crates/adapters/postgres/migrations/007_content_text.sql @@ -0,0 +1,3 @@ +-- Remote ActivityPub posts can exceed 128 characters. +-- The 128-char limit is enforced at the application layer for local posts only. +ALTER TABLE thoughts ALTER COLUMN content TYPE TEXT; diff --git a/crates/adapters/postgres/migrations/008_rename_notifications_type.sql b/crates/adapters/postgres/migrations/008_rename_notifications_type.sql new file mode 100644 index 0000000..5feffaa --- /dev/null +++ b/crates/adapters/postgres/migrations/008_rename_notifications_type.sql @@ -0,0 +1 @@ +ALTER TABLE notifications RENAME COLUMN "type" TO notification_type; diff --git a/crates/adapters/postgres/migrations/009_failed_events.sql b/crates/adapters/postgres/migrations/009_failed_events.sql new file mode 100644 index 0000000..4a558a6 --- /dev/null +++ b/crates/adapters/postgres/migrations/009_failed_events.sql @@ -0,0 +1,15 @@ +CREATE TABLE failed_events ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + failed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retry_at TIMESTAMPTZ NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL, + + CONSTRAINT failed_events_pkey PRIMARY KEY (id) +); + +CREATE INDEX failed_events_due_idx + ON failed_events (retry_at) + WHERE retry_count < 3; diff --git a/crates/adapters/postgres/migrations/010_fix_reply_fk_on_delete.sql b/crates/adapters/postgres/migrations/010_fix_reply_fk_on_delete.sql new file mode 100644 index 0000000..6bf7922 --- /dev/null +++ b/crates/adapters/postgres/migrations/010_fix_reply_fk_on_delete.sql @@ -0,0 +1,11 @@ +-- Change in_reply_to_id FK from RESTRICT (default) to SET NULL. +-- Previously, deleting a thought that had replies raised a FK violation. +-- With SET NULL, deleting a thought orphans its replies (they survive but +-- lose their parent reference), which is the correct semantic for a +-- threaded social app. +ALTER TABLE thoughts + DROP CONSTRAINT IF EXISTS thoughts_in_reply_to_id_fkey; + +ALTER TABLE thoughts + ADD CONSTRAINT thoughts_in_reply_to_id_fkey + FOREIGN KEY (in_reply_to_id) REFERENCES thoughts(id) ON DELETE SET NULL; diff --git a/crates/adapters/postgres/migrations/011_outbox_events.sql b/crates/adapters/postgres/migrations/011_outbox_events.sql new file mode 100644 index 0000000..8f7e397 --- /dev/null +++ b/crates/adapters/postgres/migrations/011_outbox_events.sql @@ -0,0 +1,10 @@ +CREATE TABLE outbox_events ( + seq BIGSERIAL PRIMARY KEY, + aggregate_id UUID NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + delivered BOOLEAN NOT NULL DEFAULT false, + delivered_at TIMESTAMPTZ +); +CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false; diff --git a/crates/adapters/postgres/src/activitypub.rs b/crates/adapters/postgres/src/activitypub.rs new file mode 100644 index 0000000..39bb1ab --- /dev/null +++ b/crates/adapters/postgres/src/activitypub.rs @@ -0,0 +1,406 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; + +const MAX_REMOTE_CONTENT_CHARS: usize = 500; +const THOUGHTS_PATH_PREFIX: &str = "/thoughts/"; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; +use domain::{ + errors::DomainError, + models::thought::{Thought, Visibility}, + 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, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + content: String, + created_at: DateTime, + in_reply_to_id: Option, + content_warning: Option, + sensitive: bool, + username: String, + updated_at: Option>, + } + 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 + .into_domain() + .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), + 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>, + limit: usize, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + content: String, + created_at: DateTime, + in_reply_to_id: Option, + content_warning: Option, + sensitive: bool, + username: String, + updated_at: Option>, + } + 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 + } + .into_domain()?; + + 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), + 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: &str, + ) -> Result, DomainError> { + sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1") + .bind(actor_ap_url) + .fetch_optional(&self.pool) + .await + .into_domain() + .map(|o| o.map(UserId::from_uuid)) + } + + async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result { + if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? { + return Ok(id); + } + let new_id = uuid::Uuid::new_v4(); + // Use the last path segment as username (e.g. /users/alice → "alice"). + // Falls back to a random short id for long segments (e.g. UUID-based actor URLs). + // username column is VARCHAR(32). + let last_seg = url::Url::parse(actor_ap_url) + .ok() + .and_then(|u| { + u.path_segments() + .and_then(|mut s| s.next_back().map(|s| s.to_string())) + }) + .unwrap_or_default(); + let handle = if last_seg.is_empty() { + format!("remote_{}", &new_id.to_string()[..13]) + } else if last_seg.len() <= 32 { + last_seg + } else { + format!("remote_{}", &new_id.to_string()[..13]) + }; + sqlx::query( + "INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at) + VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING", + ) + .bind(new_id) + .bind(&handle) + .bind(format!("{}@remote", new_id)) + .bind(actor_ap_url) + .execute(&self.pool) + .await + .into_domain()?; + // Re-fetch to get whichever id won the race + self.find_remote_actor_id(actor_ap_url) + .await? + .ok_or_else(|| { + DomainError::Internal( + "intern_remote_actor: insert succeeded but row not found".into(), + ) + }) + } + + async fn update_remote_actor_display( + &self, + user_id: &UserId, + display_name: Option<&str>, + avatar_url: Option<&str>, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE users SET display_name=$1, avatar_url=$2, updated_at=NOW() + WHERE id=$3 AND local=false", + ) + .bind(display_name) + .bind(avatar_url) + .bind(user_id.as_uuid()) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn accept_note( + &self, + ap_id: &str, + author_id: &UserId, + content: &str, + published: DateTime, + sensitive: bool, + content_warning: Option, + visibility: &str, + in_reply_to: Option<&str>, + ) -> Result { + let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); + let (in_reply_to_id, in_reply_to_url) = match in_reply_to { + Some(url) => { + // If the parent is a local thought, extract its UUID for in_reply_to_id. + let local_uuid = url::Url::parse(url).ok().and_then(|u| { + u.path() + .strip_prefix(THOUGHTS_PATH_PREFIX) + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + }); + (local_uuid, Some(url.to_string())) + } + None => (None, None), + }; + sqlx::query( + "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url) + VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING", + ) + .bind(uuid::Uuid::new_v4()) + .bind(author_id.as_uuid()) + .bind(&capped) + .bind(ap_id) + .bind(sensitive) + .bind(content_warning) + .bind(published) + .bind(visibility) + .bind(in_reply_to_id) + .bind(&in_reply_to_url) + .execute(&self.pool) + .await + .into_domain()?; + + // SELECT the id — works whether the INSERT was a no-op or not (idempotent). + let row: (uuid::Uuid,) = + sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") + .bind(ap_id) + .fetch_one(&self.pool) + .await + .into_domain()?; + Ok(ThoughtId::from_uuid(row.0)) + } + + async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> { + let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); + sqlx::query( + "UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false", + ) + .bind(ap_id) + .bind(&capped) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false") + .bind(ap_id) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> { + sqlx::query( + "DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)", + ) + .bind(actor_ap_url) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn count_local_notes(&self) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true") + .fetch_one(&self.pool) + .await + .into_domain()?; + Ok(n as u64) + } + + async fn get_thought_ap_id( + &self, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + sqlx::query_scalar::<_, String>( + "SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL", + ) + .bind(thought_id.as_uuid()) + .fetch_optional(&self.pool) + .await + .into_domain() + } + + async fn get_actor_ap_urls( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + sqlx::query_as::<_, (String, String)>( + "SELECT ap_id, inbox_url FROM users \ + WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL", + ) + .bind(user_id.as_uuid()) + .fetch_optional(&self.pool) + .await + .into_domain() + .map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use activitypub_base::ActivityPubRepository; + + #[sqlx::test(migrations = "./migrations")] + async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + let url = "https://mastodon.social/users/alice"; + let id1 = repo.intern_remote_actor(url).await.unwrap(); + let id2 = repo.intern_remote_actor(url).await.unwrap(); + assert_eq!(id1, id2); + } + + #[sqlx::test(migrations = "./migrations")] + async fn accept_and_retract_note(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + let actor_url = "https://remote.example/users/bob"; + let ap_id = "https://remote.example/notes/1"; + let author = repo.intern_remote_actor(actor_url).await.unwrap(); + repo.accept_note( + ap_id, + &author, + "hello from remote", + chrono::Utc::now(), + false, + None, + "public", + None, + ) + .await + .unwrap(); + repo.retract_note(ap_id).await.unwrap(); + } + + #[sqlx::test(migrations = "./migrations")] + async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + assert_eq!(repo.count_local_notes().await.unwrap(), 0); + } + + #[sqlx::test(migrations = "./migrations")] + async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool.clone()); + let actor_user_id = repo + .intern_remote_actor("https://remote.example/users/alice") + .await + .unwrap(); + + let thought_id = repo + .accept_note( + "https://remote.example/notes/1", + &actor_user_id, + "Hello #rust world", + chrono::Utc::now(), + false, + None, + "public", + None, + ) + .await + .unwrap(); + + let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") + .bind("https://remote.example/notes/1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(thought_id.as_uuid(), row.0); + } +} diff --git a/crates/adapters/postgres/src/api_key.rs b/crates/adapters/postgres/src/api_key.rs new file mode 100644 index 0000000..18eadc5 --- /dev/null +++ b/crates/adapters/postgres/src/api_key.rs @@ -0,0 +1,142 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::api_key::ApiKey, + ports::ApiKeyRepository, + value_objects::{ApiKeyId, UserId}, +}; +use sqlx::PgPool; + +pub struct PgApiKeyRepository { + pool: PgPool, +} +impl PgApiKeyRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ApiKeyRepository for PgApiKeyRepository { + async fn save(&self, k: &ApiKey) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)", + ) + .bind(k.id.as_uuid()) + .bind(k.user_id.as_uuid()) + .bind(&k.key_hash) + .bind(&k.name) + .bind(k.created_at) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + key_hash: String, + name: String, + created_at: DateTime, + } + 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 + .into_domain() + .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, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + key_hash: String, + name: String, + created_at: DateTime, + } + 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 + .into_domain() + .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 + .into_domain() + .map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::user::PgUserRepository; + use chrono::Utc; + use domain::ports::UserWriter; + use domain::{models::user::User, value_objects::*}; + + async fn seed_user(pool: &sqlx::PgPool) -> User { + let repo = PgUserRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u + } + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_by_hash(pool: sqlx::PgPool) { + let user = seed_user(&pool).await; + let repo = PgApiKeyRepository::new(pool); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "abc123".into(), + name: "test".into(), + created_at: Utc::now(), + }; + repo.save(&key).await.unwrap(); + let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); + assert_eq!(found.name, "test"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_key(pool: sqlx::PgPool) { + let user = seed_user(&pool).await; + let repo = PgApiKeyRepository::new(pool); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "def456".into(), + name: "key2".into(), + created_at: Utc::now(), + }; + repo.save(&key).await.unwrap(); + repo.delete(&key.id, &user.id).await.unwrap(); + assert!(repo.find_by_hash("def456").await.unwrap().is_none()); + } +} diff --git a/crates/adapters/postgres/src/block.rs b/crates/adapters/postgres/src/block.rs new file mode 100644 index 0000000..53be416 --- /dev/null +++ b/crates/adapters/postgres/src/block.rs @@ -0,0 +1,90 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use domain::{ + errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId, +}; +use sqlx::PgPool; + +pub struct PgBlockRepository { + pool: PgPool, +} +impl PgBlockRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl BlockRepository for PgBlockRepository { + async fn save(&self, b: &Block) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING" + ) + .bind(b.blocker_id.as_uuid()) + .bind(b.blocked_id.as_uuid()) + .bind(b.created_at) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") + .bind(blocker_id.as_uuid()) + .bind(blocked_id.as_uuid()) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") + .bind(blocker_id.as_uuid()) + .bind(blocked_id.as_uuid()) + .fetch_one(&self.pool) + .await + .into_domain()?; + Ok(count > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::seed_user; + use chrono::Utc; + use domain::value_objects::*; + + #[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()); + } +} diff --git a/crates/adapters/postgres/src/boost.rs b/crates/adapters/postgres/src/boost.rs new file mode 100644 index 0000000..bc1e8b9 --- /dev/null +++ b/crates/adapters/postgres/src/boost.rs @@ -0,0 +1,110 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::social::Boost, + ports::BoostRepository, + value_objects::{BoostId, ThoughtId, UserId}, +}; +use sqlx::PgPool; + +pub struct PgBoostRepository { + pool: PgPool, +} +impl PgBoostRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl BoostRepository for PgBoostRepository { + async fn save(&self, b: &Boost) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING" + ) + .bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at) + .execute(&self.pool).await.into_domain().map(|_| ()) + } + + async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { + let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2") + .bind(user_id.as_uuid()) + .bind(thought_id.as_uuid()) + .execute(&self.pool) + .await + .into_domain()?; + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } + Ok(()) + } + + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + thought_id: uuid::Uuid, + ap_id: Option, + created_at: DateTime, + } + sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2") + .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) + .fetch_optional(&self.pool).await + .into_domain() + .map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at })) + } + + async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1") + .bind(thought_id.as_uuid()) + .fetch_one(&self.pool) + .await + .into_domain() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::seed_user_and_thought; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn boost_and_count(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgBoostRepository::new(pool); + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&boost).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); + } + + #[sqlx::test(migrations = "./migrations")] + async fn unboost(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgBoostRepository::new(pool); + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&boost).await.unwrap(); + repo.delete(&user.id, &thought.id).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); + } +} diff --git a/crates/adapters/postgres/src/db_error.rs b/crates/adapters/postgres/src/db_error.rs new file mode 100644 index 0000000..b30bf44 --- /dev/null +++ b/crates/adapters/postgres/src/db_error.rs @@ -0,0 +1,20 @@ +use domain::errors::DomainError; + +pub(crate) trait IntoDbResult { + fn into_domain(self) -> Result; +} + +impl IntoDbResult for Result { + fn into_domain(self) -> Result { + self.map_err(|e| { + if let sqlx::Error::Database(ref db) = e { + if db.code().as_deref() == Some("23505") { + return DomainError::Conflict( + db.constraint().unwrap_or("conflict").to_string(), + ); + } + } + DomainError::Internal(e.to_string()) + }) + } +} diff --git a/crates/adapters/postgres/src/engagement.rs b/crates/adapters/postgres/src/engagement.rs new file mode 100644 index 0000000..d5cb4d0 --- /dev/null +++ b/crates/adapters/postgres/src/engagement.rs @@ -0,0 +1,83 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::feed::{EngagementStats, ViewerContext}, + ports::EngagementRepository, + value_objects::{ThoughtId, UserId}, +}; +use sqlx::PgPool; +use std::collections::HashMap; + +pub struct PgEngagementRepository { + pool: PgPool, +} + +impl PgEngagementRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl EngagementRepository for PgEngagementRepository { + async fn get_for_thoughts( + &self, + thought_ids: &[ThoughtId], + viewer_id: Option<&UserId>, + ) -> Result)>, DomainError> { + if thought_ids.is_empty() { + return Ok(HashMap::new()); + } + + #[derive(sqlx::FromRow)] + struct Row { + thought_id: uuid::Uuid, + like_count: i64, + boost_count: i64, + reply_count: i64, + liked_by_viewer: bool, + boosted_by_viewer: bool, + } + + let ids: Vec = thought_ids.iter().map(|t| t.as_uuid()).collect(); + let viewer_uuid: Option = viewer_id.map(|v| v.as_uuid()); + + let rows = sqlx::query_as::<_, Row>( + "SELECT + t.id AS thought_id, + COUNT(DISTINCT l.user_id) AS like_count, + COUNT(DISTINCT b.user_id) AS boost_count, + COUNT(DISTINCT r.id) AS reply_count, + COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer, + COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer + FROM thoughts t + LEFT JOIN likes l ON l.thought_id = t.id + LEFT JOIN boosts b ON b.thought_id = t.id + LEFT JOIN thoughts r ON r.in_reply_to_id = t.id + WHERE t.id = ANY($1) + GROUP BY t.id", + ) + .bind(&ids[..]) + .bind(viewer_uuid) + .fetch_all(&self.pool) + .await + .into_domain()?; + + let mut result = HashMap::new(); + for row in rows { + let tid = ThoughtId::from_uuid(row.thought_id); + let stats = EngagementStats { + like_count: row.like_count, + boost_count: row.boost_count, + reply_count: row.reply_count, + }; + let viewer = viewer_id.map(|_| ViewerContext { + liked: row.liked_by_viewer, + boosted: row.boosted_by_viewer, + }); + result.insert(tid, (stats, viewer)); + } + Ok(result) + } +} diff --git a/crates/adapters/postgres/src/failed_event.rs b/crates/adapters/postgres/src/failed_event.rs new file mode 100644 index 0000000..9aa79fb --- /dev/null +++ b/crates/adapters/postgres/src/failed_event.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +/// How many times a failed event is retried by the DLQ processor. +pub const DLQ_MAX_RETRIES: i32 = 3; +/// Quarantine period for the first DLQ retry (seconds). Doubles each retry. +pub const DLQ_INITIAL_BACKOFF_SECS: i64 = 300; // 5 minutes +/// How often the DLQ processor polls for due retries (seconds). +pub const DLQ_POLL_INTERVAL_SECS: u64 = 60; + +#[derive(sqlx::FromRow)] +pub struct FailedEvent { + pub id: uuid::Uuid, + pub event_type: String, + pub payload: serde_json::Value, + pub failed_at: DateTime, + pub retry_at: DateTime, + pub retry_count: i32, + pub last_error: String, +} + +pub struct PgFailedEventStore { + pool: PgPool, +} + +impl PgFailedEventStore { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Insert a newly exhausted event into the DLQ. + pub async fn insert( + &self, + event_type: &str, + payload: &serde_json::Value, + last_error: &str, + ) -> Result<(), sqlx::Error> { + let retry_at = Utc::now() + chrono::Duration::seconds(DLQ_INITIAL_BACKOFF_SECS); + sqlx::query( + "INSERT INTO failed_events \ + (event_type, payload, retry_at, last_error) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(event_type) + .bind(payload) + .bind(retry_at) + .bind(last_error) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Fetch all events due for retry (retry_at <= now, retry_count < DLQ_MAX_RETRIES). + pub async fn poll_due(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, FailedEvent>( + "SELECT id, event_type, payload, failed_at, retry_at, retry_count, last_error \ + FROM failed_events \ + WHERE retry_at <= now() AND retry_count < $1 \ + ORDER BY retry_at \ + LIMIT 100", + ) + .bind(DLQ_MAX_RETRIES) + .fetch_all(&self.pool) + .await + } + + /// Advance a row after a republish attempt using exponential backoff. + /// next_retry = now + initial * 2^retry_count + pub async fn advance(&self, id: uuid::Uuid, error: Option<&str>) -> Result<(), sqlx::Error> { + let current: i32 = + sqlx::query_scalar("SELECT retry_count FROM failed_events WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await?; + + let new_count = current + 1; + let backoff_secs = DLQ_INITIAL_BACKOFF_SECS * (1_i64 << new_count.min(10)); + let retry_at = Utc::now() + chrono::Duration::seconds(backoff_secs); + let last_error = error.unwrap_or("republish succeeded"); + + sqlx::query( + "UPDATE failed_events \ + SET retry_count = $1, retry_at = $2, last_error = $3 \ + WHERE id = $4", + ) + .bind(new_count) + .bind(retry_at) + .bind(last_error) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Park a permanently failed event (retry_count >= DLQ_MAX_RETRIES). + pub async fn park_permanently(&self, id: uuid::Uuid) -> Result<(), sqlx::Error> { + let far_future = Utc::now() + chrono::Duration::days(365); + sqlx::query("UPDATE failed_events SET retry_at = $1 WHERE id = $2") + .bind(far_future) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/crates/adapters/postgres/src/feed.rs b/crates/adapters/postgres/src/feed.rs new file mode 100644 index 0000000..d335e84 --- /dev/null +++ b/crates/adapters/postgres/src/feed.rs @@ -0,0 +1,399 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use domain::{ + errors::DomainError, + models::{ + feed::{FeedEntry, Paginated}, + thought::{Thought, Visibility}, + user::User, + }, + ports::{FeedQuery, FeedRepository, FeedScope}, + 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, + visibility: String, + content_warning: Option, + sensitive: bool, + t_local: bool, + thought_created_at: DateTime, + updated_at: Option>, + author_id: uuid::Uuid, + username: String, + email: String, + password_hash: String, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + author_local: bool, + author_created_at: DateTime, + author_updated_at: DateTime, + like_count: i64, + boost_count: i64, + reply_count: i64, + liked_by_viewer: bool, + boosted_by_viewer: bool, +} + +fn federation_following_clause(follower: Option) -> String { + match follower { + Some(fid) => format!( + " OR t.user_id IN ( + SELECT u2.id FROM users u2 + JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url + WHERE ff.local_user_id = '{fid}' + )" + ), + None => String::new(), + } +} + +fn feed_select(viewer: Option) -> 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.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, + CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != '' + THEN '@' || ra.handle || + CASE WHEN ra.handle NOT LIKE '%@%' + THEN '@' || SPLIT_PART(ra.url, '/', 3) + ELSE '' END + ELSE u.username END AS username, + u.email, u.password_hash, + COALESCE(ra.display_name, u.display_name) AS display_name, + u.bio, + COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url, + u.header_url, u.custom_css, + u.local AS author_local, + 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 + LEFT JOIN remote_actors ra ON u.ap_id = ra.url" + ) +} + +fn row_to_entry(r: FeedRow, viewer: Option) -> Result { + let thought = Thought { + id: ThoughtId::from_uuid(r.thought_id), + user_id: UserId::from_uuid(r.t_user_id), + content: Content::new_remote(r.content), + in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), + visibility: Visibility::from_db_str(&r.visibility)?, + content_warning: r.content_warning, + sensitive: r.sensitive, + local: r.t_local, + created_at: r.thought_created_at, + updated_at: r.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, + created_at: r.author_created_at, + updated_at: r.author_updated_at, + }; + Ok(FeedEntry { + thought, + author, + stats: domain::models::feed::EngagementStats { + like_count: r.like_count, + boost_count: r.boost_count, + reply_count: r.reply_count, + }, + viewer: viewer.map(|_| domain::models::feed::ViewerContext { + liked: r.liked_by_viewer, + boosted: r.boosted_by_viewer, + }), + }) +} + +#[async_trait] +impl FeedRepository for PgFeedRepository { + async fn query(&self, q: &FeedQuery) -> Result, DomainError> { + let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid()); + let page = &q.page; + + match &q.scope { + FeedScope::Home { following_ids } => { + let ids: Vec = following_ids.iter().map(|id| id.as_uuid()).collect(); + let fed_clause = federation_following_clause(viewer); + let count_sql = format!( + "SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'", + fed_clause + ); + let total: i64 = sqlx::query_scalar(&count_sql) + .bind(&ids) + .fetch_one(&self.pool) + .await + .into_domain()?; + + 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", fed_clause); + let rows = sqlx::query_as::<_, FeedRow>(&sql) + .bind(&ids) + .bind(page.limit()) + .bind(page.offset()) + .fetch_all(&self.pool) + .await + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(|r| row_to_entry(r, viewer)) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + FeedScope::Public => { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'", + ) + .fetch_one(&self.pool) + .await + .into_domain()?; + + 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 + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(|r| row_to_entry(r, viewer)) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + FeedScope::Search { query } => { + 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 + .into_domain()?; + + 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 + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(|r| row_to_entry(r, viewer)) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + FeedScope::Tag { tag_name } => { + 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 + .into_domain()?; + + 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 + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(|r| row_to_entry(r, viewer)) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + FeedScope::User { user_id } => { + 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 + .into_domain()?; + + 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 + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(|r| row_to_entry(r, viewer)) + .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::{ + feed::PageParams, + thought::{Thought, Visibility}, + user::User, + }, + ports::{FeedQuery, ThoughtRepository, UserWriter}, + 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 + .query(&FeedQuery::public( + 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 + .query(&FeedQuery::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")); + } +} diff --git a/crates/adapters/postgres/src/follow.rs b/crates/adapters/postgres/src/follow.rs new file mode 100644 index 0000000..6bd2286 --- /dev/null +++ b/crates/adapters/postgres/src/follow.rs @@ -0,0 +1,252 @@ +use crate::db_error::IntoDbResult; +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 + .into_domain() + .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 + .into_domain()?; + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } + Ok(()) + } + + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + follower_id: uuid::Uuid, + following_id: uuid::Uuid, + state: String, + ap_id: Option, + created_at: DateTime, + } + 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 + .into_domain() + .and_then(|o| { + o.map(|r| { + Ok(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, + }) + }) + .transpose() + }) + } + + 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 + .into_domain() + .map(|_| ()) + } + + async fn list_followers( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, 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 + .into_domain()?; + + let rows = sqlx::query_as::<_, crate::user::UserRow>( + "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,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 + .into_domain()?; + + 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, 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 + .into_domain()?; + + let rows = sqlx::query_as::<_, crate::user::UserRow>( + "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,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 + .into_domain()?; + + 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, DomainError> { + let ids: Vec = 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 + .into_domain()?; + Ok(ids.into_iter().map(UserId::from_uuid).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::seed_user; + use chrono::Utc; + use domain::value_objects::*; + + #[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]); + } +} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs new file mode 100644 index 0000000..7694f24 --- /dev/null +++ b/crates/adapters/postgres/src/lib.rs @@ -0,0 +1,20 @@ +pub mod activitypub; +pub mod engagement; +pub mod api_key; +pub mod block; +pub mod boost; +mod db_error; +pub mod failed_event; +pub mod outbox; +pub mod feed; +pub mod follow; +pub mod like; +pub mod notification; +pub mod remote_actor; +pub mod remote_actor_connections; +pub mod tag; +#[cfg(test)] +pub(crate) mod test_helpers; +pub mod thought; +pub mod top_friend; +pub mod user; diff --git a/crates/adapters/postgres/src/like.rs b/crates/adapters/postgres/src/like.rs new file mode 100644 index 0000000..43d5d59 --- /dev/null +++ b/crates/adapters/postgres/src/like.rs @@ -0,0 +1,110 @@ +use crate::db_error::IntoDbResult; +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.into_domain().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 + .into_domain()?; + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } + Ok(()) + } + + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + thought_id: uuid::Uuid, + ap_id: Option, + created_at: DateTime, + } + 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 + .into_domain() + .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 { + sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1") + .bind(thought_id.as_uuid()) + .fetch_one(&self.pool) + .await + .into_domain() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::seed_user_and_thought; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn like_and_count(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&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_user_and_thought(&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); + } +} diff --git a/crates/adapters/postgres/src/notification.rs b/crates/adapters/postgres/src/notification.rs new file mode 100644 index 0000000..77b4cbb --- /dev/null +++ b/crates/adapters/postgres/src/notification.rs @@ -0,0 +1,230 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use domain::{ + errors::DomainError, + models::{ + feed::{PageParams, Paginated}, + notification::{Notification, NotificationKind}, + }, + 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 } + } +} + +#[derive(sqlx::FromRow)] +struct NotificationRow { + id: uuid::Uuid, + user_id: uuid::Uuid, + notification_type: String, + from_user_id: Option, + thought_id: Option, + read: bool, + created_at: DateTime, +} + +fn row_to_notification(r: NotificationRow) -> Result { + let from_user_id = r + .from_user_id + .map(UserId::from_uuid) + .ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?; + + let kind = match r.notification_type.as_str() { + "follow" => NotificationKind::Follow { from_user_id }, + other => { + let thought_id = r.thought_id.map(ThoughtId::from_uuid).ok_or_else(|| { + DomainError::Internal(format!("notification type '{other}' missing thought_id")) + })?; + match other { + "like" => NotificationKind::Like { + thought_id, + from_user_id, + }, + "boost" => NotificationKind::Boost { + thought_id, + from_user_id, + }, + "reply" => NotificationKind::Reply { + thought_id, + from_user_id, + }, + "mention" => NotificationKind::Mention { + thought_id, + from_user_id, + }, + _ => { + return Err(DomainError::Internal(format!( + "unknown notification type: {other}" + ))) + } + } + } + }; + + Ok(Notification { + id: NotificationId::from_uuid(r.id), + user_id: UserId::from_uuid(r.user_id), + kind, + read: r.read, + created_at: r.created_at, + }) +} + +#[async_trait] +impl NotificationRepository for PgNotificationRepository { + async fn save(&self, n: &Notification) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT(id) DO NOTHING" + ) + .bind(n.id.as_uuid()) + .bind(n.user_id.as_uuid()) + .bind(n.kind.kind_str()) + .bind(n.kind.from_user_id().as_uuid()) + .bind(n.kind.thought_id().map(|t| t.as_uuid())) + .bind(n.read) + .bind(n.created_at) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn list_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, 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 + .into_domain()?; + let rows = sqlx::query_as::<_, NotificationRow>( + "SELECT id,user_id,notification_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.into_domain()?; + let items = rows + .into_iter() + .map(row_to_notification) + .collect::, _>>()?; + Ok(Paginated { + items, + total, + page: page.page, + per_page: page.per_page, + }) + } + + async fn count_unread(&self, user_id: &UserId) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM notifications WHERE user_id=$1 AND read=false", + ) + .bind(user_id.as_uuid()) + .fetch_one(&self.pool) + .await + .into_domain()?; + Ok(count as u64) + } + + 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 + .into_domain() + .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 + .into_domain() + .map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers; + use chrono::Utc; + use domain::{ + models::{notification::NotificationKind, user::User}, + value_objects::*, + }; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_list(pool: sqlx::PgPool) { + let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; + let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgNotificationRepository::new(pool); + use domain::models::feed::PageParams; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + kind: NotificationKind::Follow { + from_user_id: from_user.id.clone(), + }, + 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 = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; + let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgNotificationRepository::new(pool); + use domain::models::feed::PageParams; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + kind: NotificationKind::Follow { + from_user_id: from_user.id.clone(), + }, + 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); + } +} diff --git a/crates/adapters/postgres/src/outbox.rs b/crates/adapters/postgres/src/outbox.rs new file mode 100644 index 0000000..a24f6bb --- /dev/null +++ b/crates/adapters/postgres/src/outbox.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, events::DomainEvent, ports::OutboxWriter}; +use event_payload::EventPayload; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct PgOutboxWriter { + pool: PgPool, +} + +impl PgOutboxWriter { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +/// Primary aggregate UUID for an event — used to populate `aggregate_id`. +fn aggregate_id(event: &DomainEvent) -> Uuid { + match event { + DomainEvent::ThoughtCreated { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::ThoughtDeleted { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::ThoughtUpdated { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::LikeAdded { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::LikeRemoved { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::BoostAdded { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::BoostRemoved { thought_id, .. } => thought_id.as_uuid(), + DomainEvent::FollowRequested { follower_id, .. } => follower_id.as_uuid(), + DomainEvent::FollowAccepted { follower_id, .. } => follower_id.as_uuid(), + DomainEvent::FollowRejected { follower_id, .. } => follower_id.as_uuid(), + DomainEvent::Unfollowed { follower_id, .. } => follower_id.as_uuid(), + DomainEvent::UserBlocked { blocker_id, .. } => blocker_id.as_uuid(), + DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(), + DomainEvent::UserRegistered { user_id } => user_id.as_uuid(), + DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(), + DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(), + } +} + +#[async_trait] +impl OutboxWriter for PgOutboxWriter { + async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> { + let payload = EventPayload::from(event); + let event_type = payload.subject(); + let payload_json = + serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?; + let agg_id = aggregate_id(event); + + sqlx::query( + "INSERT INTO outbox_events (aggregate_id, event_type, payload) \ + VALUES ($1, $2, $3)", + ) + .bind(agg_id) + .bind(event_type) + .bind(payload_json) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + Ok(()) + } +} diff --git a/crates/adapters/postgres/src/remote_actor.rs b/crates/adapters/postgres/src/remote_actor.rs new file mode 100644 index 0000000..6f4aecf --- /dev/null +++ b/crates/adapters/postgres/src/remote_actor.rs @@ -0,0 +1,59 @@ +use crate::db_error::IntoDbResult; +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,avatar_url,last_fetched_at) + VALUES($1,$2,$3,$4,$5) + ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name, + avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at" + ) + .bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at) + .execute(&self.pool).await.into_domain().map(|_| ()) + } + + async fn find_by_url(&self, url: &str) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + url: String, + handle: String, + display_name: Option, + avatar_url: Option, + last_fetched_at: DateTime, + } + sqlx::query_as::<_, Row>( + "SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1" + ).bind(url).fetch_optional(&self.pool).await + .into_domain() + .map(|o| o.map(|r| RemoteActor { + url: r.url, + handle: r.handle, + display_name: r.display_name, + avatar_url: r.avatar_url, + last_fetched_at: r.last_fetched_at, + bio: None, + banner_url: None, + also_known_as: None, + outbox_url: None, + followers_url: None, + following_url: None, + attachment: vec![], + })) + } +} diff --git a/crates/adapters/postgres/src/remote_actor_connections.rs b/crates/adapters/postgres/src/remote_actor_connections.rs new file mode 100644 index 0000000..c2a92c9 --- /dev/null +++ b/crates/adapters/postgres/src/remote_actor_connections.rs @@ -0,0 +1,111 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use domain::{ + errors::DomainError, models::actor_connection_summary::ActorConnectionSummary, + ports::RemoteActorConnectionRepository, +}; +use sqlx::PgPool; + +pub struct PgRemoteActorConnectionRepository { + pool: PgPool, +} + +impl PgRemoteActorConnectionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository { + async fn upsert_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + actors: &[ActorConnectionSummary], + ) -> Result<(), DomainError> { + for actor in actors { + sqlx::query( + "INSERT INTO remote_actor_connections + (actor_url, connection_type, page, connected_actor_url, + connected_handle, connected_display_name, connected_avatar_url, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT(actor_url, connection_type, page, connected_actor_url) + DO UPDATE SET + connected_handle = EXCLUDED.connected_handle, + connected_display_name = EXCLUDED.connected_display_name, + connected_avatar_url = EXCLUDED.connected_avatar_url, + fetched_at = NOW()", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .bind(&actor.url) + .bind(&actor.handle) + .bind(&actor.display_name) + .bind(&actor.avatar_url) + .execute(&self.pool) + .await + .into_domain()?; + } + Ok(()) + } + + async fn list_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + connected_actor_url: String, + connected_handle: String, + connected_display_name: Option, + connected_avatar_url: Option, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url + FROM remote_actor_connections + WHERE actor_url = $1 AND connection_type = $2 AND page = $3 + ORDER BY connected_handle", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .fetch_all(&self.pool) + .await + .into_domain()?; + + Ok(rows + .into_iter() + .map(|r| ActorConnectionSummary { + url: r.connected_actor_url, + handle: r.connected_handle, + display_name: r.connected_display_name, + avatar_url: r.connected_avatar_url, + }) + .collect()) + } + + async fn connection_page_age( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result>, DomainError> { + let row: Option<(Option>,)> = sqlx::query_as( + "SELECT MAX(fetched_at) FROM remote_actor_connections + WHERE actor_url = $1 AND connection_type = $2 AND page = $3", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .fetch_optional(&self.pool) + .await + .into_domain()?; + + Ok(row.and_then(|(ts,)| ts)) + } +} diff --git a/crates/adapters/postgres/src/tag.rs b/crates/adapters/postgres/src/tag.rs new file mode 100644 index 0000000..ab692a9 --- /dev/null +++ b/crates/adapters/postgres/src/tag.rs @@ -0,0 +1,184 @@ +use crate::db_error::IntoDbResult; +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 { + let name = name.to_lowercase(); + sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING") + .bind(&name) + .execute(&self.pool) + .await + .into_domain()?; + #[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 + .into_domain()?; + 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 + .into_domain() + .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 + .into_domain() + .map(|_| ()) + } + + async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, 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 + .into_domain() + .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, 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 + .into_domain()?; + + 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.into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(Thought::try_from) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } + + async fn popular_tags(&self, limit: usize) -> Result, 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 + .into_domain() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use domain::ports::{ThoughtRepository, UserWriter}; + 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"); + } +} diff --git a/crates/adapters/postgres/src/test_helpers.rs b/crates/adapters/postgres/src/test_helpers.rs new file mode 100644 index 0000000..ea0262d --- /dev/null +++ b/crates/adapters/postgres/src/test_helpers.rs @@ -0,0 +1,37 @@ +use crate::{thought::PgThoughtRepository, user::PgUserRepository}; +use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + ports::{ThoughtRepository, UserWriter}, + value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, +}; + +pub 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 +} + +pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) { + let user = seed_user(pool, "alice", "alice@ex.com").await; + let trepo = PgThoughtRepository::new(pool.clone()); + let t = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + ); + trepo.save(&t).await.unwrap(); + (user, t) +} diff --git a/crates/adapters/postgres/src/thought.rs b/crates/adapters/postgres/src/thought.rs new file mode 100644 index 0000000..3a0cf7d --- /dev/null +++ b/crates/adapters/postgres/src/thought.rs @@ -0,0 +1,262 @@ +use crate::db_error::IntoDbResult; +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, + pub visibility: String, + pub content_warning: Option, + pub sensitive: bool, + pub local: bool, + pub created_at: DateTime, + pub updated_at: Option>, +} + +impl TryFrom for Thought { + type Error = DomainError; + fn try_from(r: ThoughtRow) -> Result { + Ok(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), + 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,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,visibility,content_warning,sensitive,local,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + 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.visibility.as_str()) + .bind(&t.content_warning) + .bind(t.sensitive) + .bind(t.local) + .bind(t.created_at) + .execute(&self.pool) + .await + .into_domain() + .map(|_| ()) + } + + async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { + sqlx::query_as::<_, ThoughtRow>(&format!("{THOUGHT_SELECT} WHERE id=$1")) + .bind(id.as_uuid()) + .fetch_optional(&self.pool) + .await + .into_domain() + .and_then(|o| o.map(Thought::try_from).transpose()) + } + + 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 + .into_domain()?; + 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 + .into_domain() + .map(|_| ()) + } + + async fn get_thread(&self, id: &ThoughtId) -> Result, 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, + 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.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 + .into_domain() + .and_then(|rows| rows.into_iter().map(Thought::try_from).collect()) + } + + async fn list_by_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, 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 + .into_domain()?; + + 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 + .into_domain()?; + + Ok(Paginated { + items: rows + .into_iter() + .map(Thought::try_from) + .collect::, _>>()?, + total, + page: page.page, + per_page: page.per_page, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::seed_user; + use domain::{ + models::thought::{Thought, Visibility}, + value_objects::*, + }; + + #[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); + } +} diff --git a/crates/adapters/postgres/src/top_friend.rs b/crates/adapters/postgres/src/top_friend.rs new file mode 100644 index 0000000..0528e18 --- /dev/null +++ b/crates/adapters/postgres/src/top_friend.rs @@ -0,0 +1,155 @@ +use crate::db_error::IntoDbResult; +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.into_domain()?; + sqlx::query("DELETE FROM top_friends WHERE user_id=$1") + .bind(user_id.as_uuid()) + .execute(&mut *tx) + .await + .into_domain()?; + 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 + .into_domain()?; + } + tx.commit().await.into_domain() + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, 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, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + local: bool, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + } + 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.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 + .into_domain()?; + + 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, + 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::UserWriter; + 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"); + } +} diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs new file mode 100644 index 0000000..9019b16 --- /dev/null +++ b/crates/adapters/postgres/src/user.rs @@ -0,0 +1,352 @@ +use crate::db_error::IntoDbResult; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::feed::{PageParams, Paginated, UserSummary}, + models::user::User, + ports::{UserReader, UserWriter}, + value_objects::{Email, PasswordHash, UserId, Username}, +}; +use sqlx::PgPool; +use std::collections::HashMap; + +pub struct PgUserRepository { + pool: PgPool, +} +impl PgUserRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +pub struct UserRow { + pub id: uuid::Uuid, + pub username: String, + pub email: String, + pub password_hash: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, + pub local: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From 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, + created_at: r.created_at, + updated_at: r.updated_at, + } + } +} + +pub const USER_SELECT: &str = + "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\ + custom_css,local,created_at,updated_at FROM users"; + +#[async_trait] +impl UserReader for PgUserRepository { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1")) + .bind(id.as_uuid()) + .fetch_optional(&self.pool) + .await + .into_domain() + .map(|o| o.map(User::from)) + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE username=$1")) + .bind(username.as_str()) + .fetch_optional(&self.pool) + .await + .into_domain() + .map(|o| o.map(User::from)) + } + + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE email=$1")) + .bind(email.as_str()) + .fetch_optional(&self.pool) + .await + .into_domain() + .map(|o| o.map(User::from)) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + display_name: Option, + avatar_url: Option, + bio: Option, + 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 + .into_domain()?; + + 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 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true") + .fetch_one(&self.pool) + .await + .into_domain() + } + + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + display_name: Option, + avatar_url: Option, + bio: Option, + thought_count: i64, + follower_count: i64, + following_count: i64, + total: 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, + COUNT(*) OVER() AS total + 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 + LIMIT $1 OFFSET $2", + ) + .bind(page.limit()) + .bind(page.offset()) + .fetch_all(&self.pool) + .await + .into_domain()?; + + let total = rows.first().map(|r| r.total).unwrap_or(0); + let items = 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(); + Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) + } + + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + let uuids: Vec = ids.iter().map(|id| id.as_uuid()).collect(); + let rows = sqlx::query_as::<_, UserRow>( + &format!("{USER_SELECT} WHERE id = ANY($1)") + ) + .bind(&uuids[..]) + .fetch_all(&self.pool) + .await + .into_domain()?; + + Ok(rows.into_iter().map(|r| { + let user = User::from(r); + (user.id.clone(), user) + }).collect()) + } +} + +#[async_trait] +impl UserWriter for PgUserRepository { + 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,created_at,updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + 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, + 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.created_at) + .bind(user.updated_at) + .execute(&self.pool) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db) = e { + if db.code().as_deref() == Some("23505") { + return match db.constraint().unwrap_or("") { + "users_username_key" => DomainError::UniqueViolation { field: "username" }, + "users_email_key" => DomainError::UniqueViolation { field: "email" }, + _ => DomainError::UniqueViolation { field: "unknown" }, + }; + } + } + DomainError::Internal(e.to_string()) + }) + .map(|_| ()) + } + + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> 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 + .into_domain() + .map(|_| ()) + } +} + +#[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")); + } +} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml new file mode 100644 index 0000000..10b1a48 --- /dev/null +++ b/crates/api-types/Cargo.toml @@ -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"] } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs new file mode 100644 index 0000000..116da0f --- /dev/null +++ b/crates/api-types/src/lib.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod responses; diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs new file mode 100644 index 0000000..c24f231 --- /dev/null +++ b/crates/api-types/src/requests.rs @@ -0,0 +1,92 @@ +use serde::Deserialize; +use uuid::Uuid; + +pub const DEFAULT_PAGE: u64 = 1; +pub const DEFAULT_PER_PAGE: u64 = 20; +pub const MAX_PER_PAGE: u64 = 100; + +#[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, + /// One of: "public", "followers", "unlisted", "direct" + pub visibility: Option, + pub content_warning: Option, + pub sensitive: Option, +} + +#[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, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetTopFriendsRequest { + /// Ordered list of user UUIDs, max 8 + pub friend_ids: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateApiKeyRequest { + pub name: String, +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct PaginationQuery { + pub page: Option, + pub per_page: Option, +} + +impl PaginationQuery { + pub fn page(&self) -> u64 { + self.page.unwrap_or(DEFAULT_PAGE).max(DEFAULT_PAGE) + } + + pub fn per_page(&self) -> u64 { + self.per_page.unwrap_or(DEFAULT_PER_PAGE).min(MAX_PER_PAGE) + } +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct SearchQuery { + pub q: String, + pub page: Option, + pub per_page: Option, +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationUpdateRequest { + pub read: bool, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs new file mode 100644 index 0000000..fa6e9b8 --- /dev/null +++ b/crates/api-types/src/responses.rs @@ -0,0 +1,137 @@ +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, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, + pub local: bool, + pub is_followed_by_viewer: bool, + #[serde(rename = "joinedAt")] + pub created_at: DateTime, +} + +#[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, + #[serde(rename = "replyToUrl", skip_serializing_if = "Option::is_none")] + pub in_reply_to_url: Option, + pub visibility: String, + pub content_warning: Option, + 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, + pub updated_at: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PagedResponse { + pub items: Vec, + 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, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationResponse { + pub id: Uuid, + pub notification_type: String, + pub from_user: Option, + pub thought_id: Option, + pub read: bool, + pub created_at: DateTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TopFriendsResponse { + pub top_friends: Vec, +} + +#[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, Clone, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProfileField { + pub name: String, + pub value: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RemoteActorResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub followers_url: Option, + pub following_url: Option, + pub attachment: Vec, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionPageResponse { + pub items: Vec, + pub page: u32, + pub has_more: bool, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..c0a7a83 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2021" + +[dependencies] +domain = { workspace = true } +activitypub-base = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +sha2 = "0.10" +hex = "0.4" +tracing = { workspace = true } +url = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..9385df2 --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,5 @@ +pub mod services; +pub mod use_cases; + +#[cfg(test)] +pub mod testing; diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs new file mode 100644 index 0000000..4c5ecb7 --- /dev/null +++ b/crates/application/src/services/federation_event.rs @@ -0,0 +1,787 @@ +use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::Visibility, + ports::{ThoughtRepository, UserReader}, + value_objects::ThoughtId, +}; +use std::sync::Arc; + +pub struct FederationEventService { + pub thoughts: Arc, + pub users: Arc, + pub ap: Arc, + pub base_url: String, + pub ap_repo: Arc, +} + +impl FederationEventService { + async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result { + if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? { + return Ok(ap_id); + } + Ok(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(()), + }; + // Resolve in_reply_to_url for the parent thought via AP repo. + let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { + let ap_id = self + .ap_repo + .get_thought_ap_id(reply_id) + .await? + .unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id)); + Some(ap_id) + } else { + None + }; + self.ap + .broadcast_create( + user_id, + &thought, + user.username.as_str(), + in_reply_to_url.as_deref(), + ) + .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(()), + }; + let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { + self.ap_repo + .get_thought_ap_id(reply_id) + .await? + .or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id))) + } else { + None + }; + self.ap + .broadcast_update( + user_id, + &thought, + user.username.as_str(), + in_reply_to_url.as_deref(), + ) + .await + } + + DomainEvent::BoostAdded { + boost_id: _, + user_id, + thought_id, + } => { + // Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast. + let booster = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = booster; + if self.thoughts.find_by_id(thought_id).await?.is_none() { + return Ok(()); + } + let object_ap_id = self.object_ap_id(thought_id).await?; + self.ap.broadcast_announce(user_id, &object_ap_id).await + } + + DomainEvent::BoostRemoved { + user_id, + thought_id, + } => { + if self.thoughts.find_by_id(thought_id).await?.is_none() { + return Ok(()); + } + let object_ap_id = self.object_ap_id(thought_id).await?; + self.ap + .broadcast_undo_announce(user_id, &object_ap_id) + .await + } + + DomainEvent::LikeAdded { + like_id: _, + user_id, + thought_id, + } => { + // Only federate: local liker + remote thought (has ap_id) + author has inbox. + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + _ => return Ok(()), + }; + let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { + Some(id) => id, + None => return Ok(()), // local thought — no federation needed + }; + let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap + .broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url) + .await + } + + DomainEvent::LikeRemoved { + user_id, + thought_id, + } => { + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + _ => return Ok(()), + }; + let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { + Some(id) => id, + None => return Ok(()), + }; + let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap + .broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url) + .await + } + + DomainEvent::ProfileUpdated { user_id } => { + self.ap.broadcast_actor_update(user_id).await + } + + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use activitypub_base::{ActorApUrls, OutboundFederationPort}; + use async_trait::async_trait; + use crate::testing::TestApRepo; + use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::{Thought, Visibility}, + models::user::User, + testing::TestStore, + value_objects::*, + }; + use std::sync::{Arc, Mutex}; + + // ── Spy port ───────────────────────────────────────────────────────────── + + #[derive(Default)] + struct SpyPort { + created: Mutex>, + deleted: Mutex>, + updated: Mutex>, + announced: Mutex>, + undo_announced: Mutex>, + liked: Mutex>, + undo_liked: Mutex>, + actor_updated: Mutex>, + } + + #[async_trait] + impl OutboundFederationPort for SpyPort { + async fn broadcast_create( + &self, + _: &UserId, + thought: &Thought, + _: &str, + _in_reply_to_url: Option<&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, + _in_reply_to_url: Option<&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(()) + } + + async fn broadcast_like( + &self, + _: &UserId, + ap_id: &str, + _: &str, + ) -> Result<(), DomainError> { + self.liked.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + + async fn broadcast_undo_like( + &self, + _: &UserId, + ap_id: &str, + _: &str, + ) -> Result<(), DomainError> { + self.undo_liked.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + + async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> { + self.actor_updated.lock().unwrap().push(user_id.clone()); + 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) -> FederationEventService { + let ap_repo = TestApRepo::new(store.clone()); + FederationEventService { + thoughts: Arc::new(store.clone()), + users: Arc::new(store.clone()), + ap: spy, + base_url: "https://example.com".to_string(), + ap_repo: Arc::new(ap_repo), + } + } + + fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc) -> FederationEventService { + FederationEventService { + thoughts: Arc::new(store.clone()), + users: Arc::new(store.clone()), + ap: spy, + base_url: "https://example.com".to_string(), + ap_repo: Arc::new(ap_repo), + } + } + + #[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 + let mut thought = local_thought(alice.id.clone()); + thought.local = 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 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.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::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; + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/users/bob/statuses/123".into(), + ); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, 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; + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/users/bob/statuses/456".into(), + ); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, 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()); + } + + #[tokio::test] + async fn like_added_local_user_remote_thought_broadcasts_like() { + let store = TestStore::default(); + + let mut author = User::new_local( + UserId::new(), + Username::new("remote_author").unwrap(), + Email::new("r@remote.example").unwrap(), + PasswordHash("h".into()), + ); + author.local = false; + let thought = local_thought(author.id.clone()); + let liker = alice(); + + store.users.lock().unwrap().push(author.clone()); + store.users.lock().unwrap().push(liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.actor_ap_urls.lock().unwrap().insert( + author.id.clone(), + ActorApUrls { + ap_id: "https://mastodon.social/users/author".into(), + inbox_url: "https://mastodon.social/users/author/inbox".into(), + }, + ); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/posts/123".into(), + ); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert_eq!(spy.liked.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn like_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); // local thought — no ap_id + + let mut remote_liker = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_liker.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: remote_liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.liked.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn boost_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); + + let mut remote_booster = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_booster.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_booster.clone()); + 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: remote_booster.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.announced.lock().unwrap().is_empty()); + } +} diff --git a/crates/application/src/services/mod.rs b/crates/application/src/services/mod.rs new file mode 100644 index 0000000..6116915 --- /dev/null +++ b/crates/application/src/services/mod.rs @@ -0,0 +1,5 @@ +pub mod federation_event; +pub mod notification_event; + +pub use federation_event::FederationEventService; +pub use notification_event::NotificationEventService; diff --git a/crates/application/src/services/notification_event.rs b/crates/application/src/services/notification_event.rs new file mode 100644 index 0000000..fc4992d --- /dev/null +++ b/crates/application/src/services/notification_event.rs @@ -0,0 +1,332 @@ +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::notification::{Notification, NotificationKind}, + ports::{NotificationRepository, ThoughtRepository}, + value_objects::NotificationId, +}; +use std::sync::Arc; + +pub struct NotificationEventService { + pub thoughts: Arc, + pub notifications: Arc, +} + +fn is_self_action( + thought_author: &domain::value_objects::UserId, + actor: &domain::value_objects::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, + kind: NotificationKind::Like { + thought_id: thought_id.clone(), + from_user_id: user_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, + kind: NotificationKind::Boost { + thought_id: thought_id.clone(), + from_user_id: user_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(), + kind: NotificationKind::Follow { + from_user_id: follower_id.clone(), + }, + 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, + kind: NotificationKind::Reply { + thought_id: thought_id.clone(), + from_user_id: user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + DomainEvent::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => { + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: mentioned_user_id.clone(), + kind: NotificationKind::Mention { + thought_id: thought_id.clone(), + from_user_id: author_user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + models::{ + notification::NotificationKind, + 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].kind, NotificationKind::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].kind, NotificationKind::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].kind, NotificationKind::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()); + } +} diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs new file mode 100644 index 0000000..b35a40c --- /dev/null +++ b/crates/application/src/testing.rs @@ -0,0 +1,150 @@ +/// Test helpers for application-layer tests that need activitypub_base traits. +use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::user::User, + testing::TestStore, + value_objects::{Email, PasswordHash, ThoughtId, UserId, Username}, +}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Extends `TestStore` with AP-specific lookup maps. +#[derive(Default, Clone)] +pub struct TestApRepo { + pub inner: TestStore, + /// UserId → ActorApUrls (for get_actor_ap_urls) + pub actor_ap_urls: Arc>>, +} + +impl TestApRepo { + pub fn new(inner: TestStore) -> Self { + Self { + inner, + actor_ap_urls: Default::default(), + } + } +} + +#[async_trait] +impl ActivityPubRepository for TestApRepo { + async fn outbox_entries_for_actor( + &self, + _uid: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn outbox_page_for_actor( + &self, + _uid: &UserId, + _before: Option>, + _limit: usize, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn find_remote_actor_id( + &self, + actor_ap_url: &str, + ) -> Result, DomainError> { + Ok(self + .inner + .actor_ap_ids + .lock() + .unwrap() + .get(actor_ap_url) + .cloned()) + } + async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result { + if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? { + return Ok(uid); + } + let uid = UserId::new(); + let handle = url::Url::parse(actor_ap_url) + .map(|u| u.path().trim_start_matches('/').replace('/', "_")) + .unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8])); + let user = User { + id: uid.clone(), + username: Username::from_trusted(handle), + email: Email::from_trusted(format!("{}@remote", uid)), + password_hash: PasswordHash("".into()), + display_name: None, + bio: None, + avatar_url: None, + header_url: None, + custom_css: None, + local: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + self.inner.users.lock().unwrap().push(user); + self.inner + .actor_ap_ids + .lock() + .unwrap() + .insert(actor_ap_url.to_string(), uid.clone()); + Ok(uid) + } + async fn update_remote_actor_display( + &self, + _user_id: &UserId, + _display_name: Option<&str>, + _avatar_url: Option<&str>, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn accept_note( + &self, + _ap_id: &str, + _author_id: &UserId, + _content: &str, + _published: chrono::DateTime, + _sensitive: bool, + _content_warning: Option, + _visibility: &str, + _in_reply_to: Option<&str>, + ) -> Result { + Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) + } + async fn apply_note_update( + &self, + _ap_id: &str, + _new_content: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn count_local_notes(&self) -> Result { + Ok(self + .inner + .thoughts + .lock() + .unwrap() + .iter() + .filter(|t| t.local) + .count() as u64) + } + async fn get_thought_ap_id( + &self, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(self + .inner + .thought_ap_ids + .lock() + .unwrap() + .get(thought_id) + .cloned()) + } + async fn get_actor_ap_urls( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned()) + } +} diff --git a/crates/application/src/use_cases/api_keys.rs b/crates/application/src/use_cases/api_keys.rs new file mode 100644 index 0000000..17e1716 --- /dev/null +++ b/crates/application/src/use_cases/api_keys.rs @@ -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, 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); + } +} diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs new file mode 100644 index 0000000..54d50dd --- /dev/null +++ b/crates/application/src/use_cases/auth.rs @@ -0,0 +1,388 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::user::User, + ports::{AuthService, EventPublisher, PasswordHasher, UserReader, 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 { + 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 + .map_err(|e| match e { + DomainError::UniqueViolation { field: "username" } => { + DomainError::Conflict("username taken".into()) + } + DomainError::UniqueViolation { field: "email" } => { + DomainError::Conflict("email taken".into()) + } + DomainError::UniqueViolation { .. } => { + DomainError::Conflict("already exists".into()) + } + other => other, + })?; + 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 UserReader, + hasher: &dyn PasswordHasher, + auth: &dyn AuthService, + input: LoginInput, +) -> Result { + let email = Email::new(input.email)?; + let user = users.find_by_email(&email).await?; + if user.is_none() { + // Timing equalization — prevents email enumeration via response-time oracle. + // Running the hasher on a miss makes "no such user" take the same time as + // "wrong password", so attackers cannot distinguish the two cases. + let _ = hasher.hash(&input.password).await; + return Err(DomainError::Unauthorized); + } + let user = user.unwrap(); + 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, + models::{feed::{PageParams, Paginated, UserSummary}, user::User}, + ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, + testing::{NoOpEventPublisher, TestStore}, + value_objects::{Email, PasswordHash, UserId, Username}, + }; + + /// Simulates a concurrent registration that slips past the pre-checks and + /// hits the DB unique constraint — exactly what happens in the TOCTOU window. + struct ConflictOnSaveStore(TestStore); + struct EmailConflictOnSaveStore(TestStore); + + #[async_trait] + impl UserReader for ConflictOnSaveStore { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_by_id(id).await + } + async fn find_by_username( + &self, + username: &Username, + ) -> Result, DomainError> { + self.0.find_by_username(username).await + } + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + self.0.find_by_email(email).await + } + async fn list_with_stats(&self) -> Result, DomainError> { + self.0.list_with_stats().await + } + async fn count(&self) -> Result { + self.0.count().await + } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } + } + + #[async_trait] + impl UserWriter for ConflictOnSaveStore { + async fn save(&self, _user: &User) -> Result<(), DomainError> { + Err(DomainError::UniqueViolation { field: "username" }) + } + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + self.0 + .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) + .await + } + } + + #[async_trait] + impl UserReader for EmailConflictOnSaveStore { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_by_id(id).await + } + async fn find_by_username( + &self, + username: &Username, + ) -> Result, DomainError> { + self.0.find_by_username(username).await + } + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + self.0.find_by_email(email).await + } + async fn list_with_stats(&self) -> Result, DomainError> { + self.0.list_with_stats().await + } + async fn count(&self) -> Result { + self.0.count().await + } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } + } + + #[async_trait] + impl UserWriter for EmailConflictOnSaveStore { + async fn save(&self, _user: &User) -> Result<(), DomainError> { + Err(DomainError::UniqueViolation { field: "email" }) + } + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + self.0 + .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) + .await + } + } + + struct FakeHasher; + #[async_trait] + impl PasswordHasher for FakeHasher { + async fn hash(&self, plain: &str) -> Result { + Ok(PasswordHash(plain.to_string())) + } + async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { + Ok(plain == hash.0) + } + } + + struct FakeAuth; + impl AuthService for FakeAuth { + fn generate_token(&self, uid: &UserId) -> Result { + Ok(GeneratedToken { + token: uid.to_string(), + user_id: uid.clone(), + }) + } + fn validate_token(&self, token: &str) -> Result { + 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(_))); + } + + /// TOCTOU: a concurrent registration slips past the pre-checks and the DB + /// unique constraint fires on save. The map_err must convert it to a + /// human-readable Conflict, not bubble up a raw constraint name. + #[tokio::test] + async fn register_maps_db_conflict_on_username_to_conflict() { + let store = ConflictOnSaveStore(TestStore::default()); + let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::Conflict(ref m) if m == "username taken"), + "expected 'username taken', got: {:?}", + err + ); + } + + #[tokio::test] + async fn register_maps_db_conflict_on_email_to_conflict() { + let store = EmailConflictOnSaveStore(TestStore::default()); + let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::Conflict(ref m) if m == "email taken"), + "expected 'email taken', got: {:?}", + err + ); + } +} diff --git a/crates/application/src/use_cases/federation_management.rs b/crates/application/src/use_cases/federation_management.rs new file mode 100644 index 0000000..f7eed3d --- /dev/null +++ b/crates/application/src/use_cases/federation_management.rs @@ -0,0 +1,191 @@ +use activitypub_base::ActivityPubRepository; +use domain::{ + errors::DomainError, + models::{ + actor_connection_summary::ActorConnectionSummary, + feed::{FeedEntry, PageParams, Paginated}, + remote_actor::RemoteActor, + }, + ports::{ + EventPublisher, FederationActionPort, FederationFollowPort, + FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository, + FollowRepository, RemoteActorConnectionRepository, UserReader, + }, + value_objects::UserId, +}; + +use super::social; + +pub async fn list_pending_requests( + federation: &dyn FederationFollowRequestPort, + user_id: &UserId, +) -> Result, DomainError> { + federation.get_pending_followers(user_id).await +} + +pub async fn accept_follow_request( + federation: &dyn FederationFollowRequestPort, + user_id: &UserId, + actor_url: &str, +) -> Result<(), DomainError> { + federation.accept_follow_request(user_id, actor_url).await +} + +pub async fn reject_follow_request( + federation: &dyn FederationFollowRequestPort, + user_id: &UserId, + actor_url: &str, +) -> Result<(), DomainError> { + federation.reject_follow_request(user_id, actor_url).await +} + +pub async fn list_remote_followers( + federation: &dyn FederationFollowRequestPort, + user_id: &UserId, +) -> Result, DomainError> { + federation.get_remote_followers(user_id).await +} + +pub async fn remove_remote_follower( + federation: &dyn FederationFollowRequestPort, + user_id: &UserId, + actor_url: &str, +) -> Result<(), DomainError> { + federation.remove_remote_follower(user_id, actor_url).await +} + +pub async fn list_remote_following( + federation: &dyn FederationFollowPort, + user_id: &UserId, +) -> Result, DomainError> { + federation.get_remote_following(user_id).await +} + +pub async fn remove_remote_following( + follows: &dyn FollowRepository, + users: &dyn UserReader, + federation: &dyn FederationFollowPort, + events: &dyn EventPublisher, + user_id: &UserId, + handle: &str, +) -> Result<(), DomainError> { + social::unfollow_actor(follows, users, federation, events, user_id, handle).await +} + +pub async fn get_remote_actor_posts( + federation: &dyn FederationActionPort, + ap_repo: &dyn ActivityPubRepository, + feed: &dyn FeedRepository, + scheduler: &dyn FederationSchedulerPort, + handle: &str, + page: PageParams, + viewer_id: Option<&UserId>, +) -> Result, DomainError> { + let actor = federation.lookup_actor(handle).await?; + let author_id = match ap_repo.find_remote_actor_id(&actor.url).await? { + Some(id) => id, + None => ap_repo.intern_remote_actor(&actor.url).await?, + }; + let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?; + if let Some(outbox_url) = actor.outbox_url { + let _ = scheduler + .schedule_actor_posts_fetch(&actor.url, &outbox_url) + .await; + } + Ok(result) +} + +const ACTOR_CONNECTIONS_CACHE_TTL_SECS: i64 = 3600; + +pub async fn get_actor_connections_page( + federation: &dyn FederationActionPort, + connections: &dyn RemoteActorConnectionRepository, + scheduler: &dyn FederationSchedulerPort, + handle: &str, + connection_type: &str, + page: u32, +) -> Result<(Vec, bool), DomainError> { + const PAGE_SIZE: usize = 20; + let actor = federation.lookup_actor(handle).await?; + let collection_url = match connection_type { + "followers" => actor.followers_url.ok_or(DomainError::NotFound)?, + _ => actor.following_url.ok_or(DomainError::NotFound)?, + }; + let items = connections + .list_connections(&actor.url, connection_type, page) + .await?; + let stale = match connections + .connection_page_age(&actor.url, connection_type, page) + .await? + { + None => true, + Some(age) => { + chrono::Utc::now().signed_duration_since(age).num_seconds() + > ACTOR_CONNECTIONS_CACHE_TTL_SECS + } + }; + if stale { + let _ = scheduler + .schedule_connections_fetch(&actor.url, &collection_url, connection_type, page) + .await; + } + let has_more = items.len() >= PAGE_SIZE; + Ok((items, has_more)) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::testing::TestStore; + + #[tokio::test] + async fn list_pending_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_pending_requests(&store, &uid).await.unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn accept_follow_request_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + accept_follow_request(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); + } + + #[tokio::test] + async fn reject_follow_request_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + reject_follow_request(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); + } + + #[tokio::test] + async fn list_remote_followers_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_remote_followers(&store, &uid).await.unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn remove_remote_follower_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); + } + + #[tokio::test] + async fn list_remote_following_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_remote_following(&store, &uid).await.unwrap(); + assert!(result.is_empty()); + } +} diff --git a/crates/application/src/use_cases/feed.rs b/crates/application/src/use_cases/feed.rs new file mode 100644 index 0000000..b384e3e --- /dev/null +++ b/crates/application/src/use_cases/feed.rs @@ -0,0 +1,17 @@ +use domain::{ + errors::DomainError, + models::feed::{FeedEntry, PageParams, Paginated}, + ports::{FeedQuery, FeedRepository, FollowRepository}, + value_objects::UserId, +}; + +pub async fn get_home_feed( + feed: &dyn FeedRepository, + follows: &dyn FollowRepository, + user_id: &UserId, + page: PageParams, +) -> Result, DomainError> { + let mut following_ids = follows.get_accepted_following_ids(user_id).await?; + following_ids.push(user_id.clone()); + feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs new file mode 100644 index 0000000..9b2cfea --- /dev/null +++ b/crates/application/src/use_cases/mod.rs @@ -0,0 +1,8 @@ +pub mod api_keys; +pub mod auth; +pub mod federation_management; +pub mod feed; +pub mod notifications; +pub mod profile; +pub mod social; +pub mod thoughts; diff --git a/crates/application/src/use_cases/notifications.rs b/crates/application/src/use_cases/notifications.rs new file mode 100644 index 0000000..7776737 --- /dev/null +++ b/crates/application/src/use_cases/notifications.rs @@ -0,0 +1,47 @@ +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, DomainError> { + repo.list_for_user(user_id, &page).await +} + +pub async fn count_unread_notifications( + repo: &dyn NotificationRepository, + user_id: &UserId, +) -> Result { + repo.count_unread(user_id).await +} + +pub async fn mark_notification_read( + repo: &dyn NotificationRepository, + id: &NotificationId, + user_id: &UserId, + is_read: bool, +) -> Result<(), DomainError> { + if is_read { + repo.mark_read(id, user_id).await + } else { + Ok(()) + } +} + +pub async fn mark_all_notifications_read( + repo: &dyn NotificationRepository, + user_id: &UserId, + is_read: bool, +) -> Result<(), DomainError> { + if is_read { + repo.mark_all_read(user_id).await + } else { + Ok(()) + } +} diff --git a/crates/application/src/use_cases/profile.rs b/crates/application/src/use_cases/profile.rs new file mode 100644 index 0000000..dbf2262 --- /dev/null +++ b/crates/application/src/use_cases/profile.rs @@ -0,0 +1,163 @@ +const MAX_TOP_FRIENDS: usize = 8; + +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{top_friend::TopFriend, user::User}, + ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter}, + value_objects::{UserId, Username}, +}; + +pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result { + users + .find_by_id(user_id) + .await? + .ok_or(DomainError::NotFound) +} + +pub async fn get_user_by_username( + users: &dyn UserReader, + username: &str, +) -> Result { + let username = Username::new(username).map_err(|_| DomainError::NotFound)?; + users + .find_by_username(&username) + .await? + .ok_or(DomainError::NotFound) +} + +/// Resolve a path segment that is either a UUID (AP actor URL) or a username. +pub async fn get_user_by_id_or_username( + users: &dyn UserReader, + id_or_username: &str, +) -> Result { + if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) { + users + .find_by_id(&UserId::from_uuid(uuid)) + .await? + .ok_or(DomainError::NotFound) + } else { + get_user_by_username(users, id_or_username).await + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn update_profile( + users: &dyn UserWriter, + events: &dyn EventPublisher, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, +) -> Result<(), DomainError> { + users + .update_profile( + user_id, + display_name, + bio, + avatar_url, + header_url, + custom_css, + ) + .await?; + events + .publish(&DomainEvent::ProfileUpdated { + user_id: user_id.clone(), + }) + .await +} + +pub async fn get_top_friends( + top_friends: &dyn TopFriendRepository, + user_id: &UserId, +) -> Result, 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, +) -> Result<(), DomainError> { + if friend_ids.len() > MAX_TOP_FRIENDS { + 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 = (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); + } +} diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social.rs new file mode 100644 index 0000000..e53bc03 --- /dev/null +++ b/crates/application/src/use_cases/social.rs @@ -0,0 +1,487 @@ +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::social::{Block, Boost, Follow, FollowState, Like}, + ports::{ + BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository, + LikeRepository, UserReader, + }, + value_objects::{BoostId, LikeId, ThoughtId, UserId, Username}, +}; + +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_actor( + follows: &dyn FollowRepository, + users: &dyn UserReader, + federation: &dyn FederationFollowPort, + events: &dyn EventPublisher, + follower_id: &UserId, + username: &str, +) -> Result<(), DomainError> { + if username.contains('@') { + federation.follow_remote(follower_id, username).await + } else { + let uname = Username::new(username) + .map_err(|_| DomainError::InvalidInput("invalid username".into()))?; + let target = users + .find_by_username(&uname) + .await? + .ok_or(DomainError::NotFound)?; + follow_user(follows, events, follower_id, &target.id).await + } +} + +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_actor( + follows: &dyn FollowRepository, + users: &dyn UserReader, + federation: &dyn FederationFollowPort, + events: &dyn EventPublisher, + follower_id: &UserId, + username: &str, +) -> Result<(), DomainError> { + if username.contains('@') { + federation.unfollow_remote(follower_id, username).await + } else { + let uname = Username::new(username) + .map_err(|_| DomainError::InvalidInput("invalid username".into()))?; + let target = users + .find_by_username(&uname) + .await? + .ok_or(DomainError::NotFound)?; + unfollow_user(follows, events, follower_id, &target.id).await + } +} + +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_by_username( + blocks: &dyn BlockRepository, + users: &dyn UserReader, + events: &dyn EventPublisher, + blocker_id: &UserId, + username: &str, +) -> Result<(), DomainError> { + let uname = Username::new(username).map_err(|_| DomainError::NotFound)?; + let target = users + .find_by_username(&uname) + .await? + .ok_or(DomainError::NotFound)?; + block_user(blocks, events, blocker_id, &target.id).await +} + +pub async fn unblock_by_username( + blocks: &dyn BlockRepository, + users: &dyn UserReader, + events: &dyn EventPublisher, + blocker_id: &UserId, + username: &str, +) -> Result<(), DomainError> { + let uname = Username::new(username).map_err(|_| DomainError::NotFound)?; + let target = users + .find_by_username(&uname) + .await? + .ok_or(DomainError::NotFound)?; + unblock_user(blocks, events, blocker_id, &target.id).await +} + +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 follow_actor_local_routes_to_follow_user() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + store.users.lock().unwrap().push(bob.clone()); + follow_actor(&store, &store, &store, &store, &alice.id, "bob") + .await + .unwrap(); + assert_eq!(store.follows.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn follow_actor_remote_routes_to_federation() { + let store = TestStore::default(); + let alice = user("alice"); + follow_actor( + &store, + &store, + &store, + &store, + &alice.id, + "@bob@example.com", + ) + .await + .unwrap(); + // TestStore.follow_remote is a no-op that returns Ok(()) + // no local follow should be recorded + assert!(store.follows.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn unfollow_actor_local_routes_to_unfollow_user() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + store.users.lock().unwrap().push(bob.clone()); + // Create an existing follow first + store + .follows + .lock() + .unwrap() + .push(domain::models::social::Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: domain::models::social::FollowState::Accepted, + ap_id: None, + created_at: chrono::Utc::now(), + }); + unfollow_actor(&store, &store, &store, &store, &alice.id, "bob") + .await + .unwrap(); + assert!(store.follows.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn unfollow_actor_remote_routes_to_federation() { + let store = TestStore::default(); + let alice = user("alice"); + unfollow_actor( + &store, + &store, + &store, + &store, + &alice.id, + "@bob@example.com", + ) + .await + .unwrap(); + // TestStore.unfollow_remote is a no-op — just verify it doesn't error + assert!(store.follows.lock().unwrap().is_empty()); + } + + #[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 { .. }))); + } +} diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs new file mode 100644 index 0000000..1fc1654 --- /dev/null +++ b/crates/application/src/use_cases/thoughts.rs @@ -0,0 +1,456 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{ + feed::{EngagementStats, FeedEntry}, + thought::{Thought, Visibility}, + }, + ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader}, + value_objects::{Content, ThoughtId, UserId}, +}; + +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, + pub visibility: Option, + pub content_warning: Option, + pub sensitive: bool, +} +pub struct CreateThoughtOutput { + pub thought: Thought, +} + +pub async fn create_thought( + thoughts: &dyn ThoughtRepository, + _users: &dyn UserReader, + tags: &dyn TagRepository, + _events: &dyn EventPublisher, + outbox: &dyn OutboxWriter, + input: CreateThoughtInput, +) -> Result { + let content = Content::new_local(input.content)?; + let visibility = match input.visibility.as_deref() { + Some("followers") => Visibility::Followers, + Some("unlisted") => Visibility::Unlisted, + Some("direct") => Visibility::Direct, + _ => 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 h in domain::hashtag::extract(content.as_str()) { + if let Ok(tag) = tags.find_or_create(&h.normalized).await { + let _ = tags.attach_to_thought(&thought.id, tag.id).await; + } + } + + outbox + .append(&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, + outbox: &dyn OutboxWriter, + 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?; + outbox + .append(&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(()) +} + +/// Fetches a single thought enriched with author + real engagement stats. +pub async fn get_thought_view( + thoughts: &dyn ThoughtRepository, + users: &dyn UserReader, + engagement: &dyn EngagementRepository, + id: &ThoughtId, + viewer: Option<&UserId>, +) -> Result { + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; + let author = users + .find_by_id(&thought.user_id) + .await? + .ok_or(DomainError::NotFound)?; + let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; + let (stats, viewer_ctx) = map.remove(id).unwrap_or( + (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) + ); + Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) +} + +/// Fetches a thread (root + replies) enriched with authors + real engagement stats. +/// Batches all DB lookups — one query per resource type regardless of thread length. +pub async fn get_thread_views( + thoughts: &dyn ThoughtRepository, + users: &dyn UserReader, + engagement: &dyn EngagementRepository, + root_id: &ThoughtId, + viewer: Option<&UserId>, +) -> Result, DomainError> { + let thread = thoughts.get_thread(root_id).await?; + if thread.is_empty() { + return Ok(vec![]); + } + + let thought_ids: Vec = thread.iter().map(|t| t.id.clone()).collect(); + let user_ids: Vec = thread.iter().map(|t| t.user_id.clone()).collect(); + + let (authors_map, engagement_map) = tokio::join!( + users.find_by_ids(&user_ids), + engagement.get_for_thoughts(&thought_ids, viewer), + ); + let authors_map = authors_map?; + let mut engagement_map = engagement_map?; + + let mut entries = Vec::with_capacity(thread.len()); + for thought in thread { + let author = authors_map + .get(&thought.user_id) + .cloned() + .ok_or(DomainError::NotFound)?; + let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( + (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) + ); + entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + models::user::User, + testing::{NoOpEventPublisher, NoOpOutboxWriter, TestOutbox, 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_stages_outbox_event() { + let store = TestStore::default(); + let outbox = TestOutbox::default(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone())) + .await + .unwrap(); + assert_eq!(out.thought.content.as_str(), "hello"); + let staged = outbox.staged(); + assert_eq!(staged.len(), 1); + assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. })); + } + + #[tokio::test] + async fn delete_thought_stages_outbox_event() { + let store = TestStore::default(); + let outbox = TestOutbox::default(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + input(u.id.clone()), + ) + .await + .unwrap(); + let tid = out.thought.id.clone(); + + delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id) + .await + .unwrap(); + + let staged = outbox.staged(); + assert_eq!(staged.len(), 1); + assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)); + } + + #[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, + &NoOpOutboxWriter, + input(u.id.clone()), + ) + .await + .unwrap(); + delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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, + &NoOpOutboxWriter, + input(alice.id.clone()), + ) + .await + .unwrap(); + let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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, &NoOpEventPublisher, &NoOpOutboxWriter, 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, + &NoOpOutboxWriter, + input(alice.id.clone()), + ) + .await + .unwrap() + .thought; + + create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + 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())); + } +} + +#[cfg(test)] +mod enrichment_tests { + use super::*; + use domain::testing::TestStore; + use domain::models::user::User; + use domain::models::thought::{Thought, Visibility}; + use domain::value_objects::*; + use domain::ports::{ThoughtRepository, UserWriter}; + + fn make_user() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("a@a.com").unwrap(), + PasswordHash("h".into()), + ) + } + + fn make_thought(user_id: UserId) -> Thought { + Thought::new_local( + ThoughtId::new(), + user_id, + Content::new_local(String::from("hello")).unwrap(), + None, + Visibility::Public, + None, + false, + ) + } + + #[tokio::test] + async fn get_thought_view_returns_feed_entry() { + let store = TestStore::default(); + let user = make_user(); + ::save(&store, &user).await.unwrap(); + let thought = make_thought(user.id.clone()); + ::save(&store, &thought).await.unwrap(); + + let entry = get_thought_view(&store, &store, &store, &thought.id, None) + .await + .unwrap(); + assert_eq!(entry.thought.id, thought.id); + assert_eq!(entry.author.id, user.id); + assert_eq!(entry.stats.like_count, 0); + assert!(entry.viewer.is_none()); + } + + #[tokio::test] + async fn get_thought_view_returns_not_found_for_missing_thought() { + let store = TestStore::default(); + let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn get_thread_views_batches_correctly() { + let store = TestStore::default(); + let user = make_user(); + ::save(&store, &user).await.unwrap(); + let root = make_thought(user.id.clone()); + ::save(&store, &root).await.unwrap(); + let reply = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local(String::from("reply")).unwrap(), + Some(root.id.clone()), + Visibility::Public, + None, + false, + ); + ::save(&store, &reply).await.unwrap(); + + let entries = get_thread_views(&store, &store, &store, &root.id, None) + .await + .unwrap(); + assert_eq!(entries.len(), 2); + } +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 0000000..0bcf085 --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -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" diff --git a/crates/bootstrap/src/config.rs b/crates/bootstrap/src/config.rs new file mode 100644 index 0000000..89141fc --- /dev/null +++ b/crates/bootstrap/src/config.rs @@ -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, + 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, +} + +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()), + } + } +} diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs new file mode 100644 index 0000000..bc1d553 --- /dev/null +++ b/crates/bootstrap/src/factory.rs @@ -0,0 +1,139 @@ +const JWT_TTL_SECS: i64 = 86_400; // 24 hours (was 30 days) +const JWT_SECRET_MIN_BYTES: usize = 32; // 256 bits minimum for HS256 + +use async_trait::async_trait; +use sqlx::PgPool; +use std::sync::Arc; + +use activitypub::ThoughtsObjectHandler; +use activitypub_base::service::ActivityPubService; +use auth::ApiKeyServiceImpl; +use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}}; +use event_transport::EventPublisherAdapter; +use nats::NatsTransport; +use postgres::activitypub::PgActivityPubRepository; +use postgres::engagement::PgEngagementRepository; +use postgres::outbox::PgOutboxWriter; +use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; +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, +} + +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 = 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, + Some(event_publisher.clone()), + Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), + )), + 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({ + if cfg.jwt_secret.len() < JWT_SECRET_MIN_BYTES { + panic!( + "JWT_SECRET is {} bytes — minimum is {} bytes for HS256 security", + cfg.jwt_secret.len(), + JWT_SECRET_MIN_BYTES, + ); + } + auth::JwtAuthService::new(cfg.jwt_secret.clone(), JWT_TTL_SECS) + }), + hasher: Arc::new(auth::Argon2PasswordHasher), + events: event_publisher, + outbox: Arc::new(PgOutboxWriter::new(pool.clone())) as Arc, + federation: ap_service.clone() as Arc, + ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), + remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), + federation_scheduler: ap_service.clone() as Arc, + api_key_auth: Arc::new(ApiKeyServiceImpl::new( + Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), + )), + engagement: Arc::new(PgEngagementRepository::new(pool.clone())), + }; + + Infrastructure { state, ap_service } +} diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs new file mode 100644 index 0000000..1b6f9b3 --- /dev/null +++ b/crates/bootstrap/src/main.rs @@ -0,0 +1,83 @@ +mod config; +mod factory; + +const MS_PER_MINUTE: u64 = 60_000; +const RATE_LIMITER_CLEANUP_INTERVAL_SECS: u64 = 60; + +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 = 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 base = presentation::routes::router() + .merge(infra.ap_service.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 = MS_PER_MINUTE.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( + RATE_LIMITER_CLEANUP_INTERVAL_SECS, + )); + 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::(), + ) + .await + .unwrap(); + } else { + axum::serve(listener, base).await.unwrap(); + } +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..ab7acae --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2021" + +[features] +test-helpers = ["dep:sha2", "dep:hex"] + +[dependencies] +async-trait = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +futures = { workspace = true } +url = { workspace = true } +sha2 = { version = "0.10", optional = true } +hex = { version = "0.4", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +sha2 = "0.10" +hex = "0.4" diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs new file mode 100644 index 0000000..e3d6a29 --- /dev/null +++ b/crates/domain/src/errors.rs @@ -0,0 +1,21 @@ +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("unique violation on field: {field}")] + UniqueViolation { field: &'static str }, + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("external service error: {0}")] + ExternalService(String), + #[error("internal error: {0}")] + Internal(String), +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs new file mode 100644 index 0000000..81382bc --- /dev/null +++ b/crates/domain/src/events.rs @@ -0,0 +1,86 @@ +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, + }, + 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, + }, + ProfileUpdated { + user_id: UserId, + }, + MentionReceived { + thought_id: ThoughtId, + mentioned_user_id: UserId, + author_user_id: UserId, + }, +} + +pub struct EventEnvelope { + pub event: DomainEvent, + pub delivery_count: u64, + pub ack: Box, + pub nack: Box, +} +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) + .field("delivery_count", &self.delivery_count) + .finish() + } +} diff --git a/crates/domain/src/hashtag.rs b/crates/domain/src/hashtag.rs new file mode 100644 index 0000000..7988a36 --- /dev/null +++ b/crates/domain/src/hashtag.rs @@ -0,0 +1,139 @@ +use std::collections::HashSet; + +/// A hashtag extracted from content. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Hashtag { + /// Original casing, e.g. "Rust" + pub raw: String, + /// Lowercased, e.g. "rust" — used for DB lookups + pub normalized: String, + /// "tags/rust" — callers prepend base_url + pub url_slug: String, + /// "#rust" — used directly in AP tag array + pub ap_name: String, +} + +/// Extract hashtags from content using a char-by-char scan. +/// +/// Rules: +/// - Tag starts after a bare `#` followed immediately by an alphanumeric char. +/// - Tag chars: `[A-Za-z0-9_]`. +/// - Deduplicated case-insensitively; first occurrence wins. +/// - Returned in order of first appearance. +pub fn extract(content: &str) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut tags: Vec = 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 raw: String = chars + .by_ref() + .take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_') + .map(|(_, nc)| nc) + .collect(); + + if raw.is_empty() { + continue; + } + + let normalized = raw.to_lowercase(); + if seen.insert(normalized.clone()) { + tags.push(Hashtag { + url_slug: format!("tags/{}", normalized), + ap_name: format!("#{}", normalized), + raw, + normalized, + }); + } + } + } + + tags +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(tags: &[Hashtag]) -> Vec<&str> { + tags.iter().map(|h| h.normalized.as_str()).collect() + } + + #[test] + fn basic() { + let tags = extract("Hello #world and #Rust!"); + assert_eq!(names(&tags), ["world", "rust"]); + } + + #[test] + fn fields() { + let tags = extract("#Rust"); + assert_eq!(tags.len(), 1); + let h = &tags[0]; + assert_eq!(h.raw, "Rust"); + assert_eq!(h.normalized, "rust"); + assert_eq!(h.url_slug, "tags/rust"); + assert_eq!(h.ap_name, "#rust"); + } + + #[test] + fn dedup_case_insensitive() { + let tags = extract("#rust #Rust #RUST"); + assert_eq!(names(&tags), ["rust"]); + assert_eq!(tags[0].raw, "rust"); // first occurrence wins + } + + #[test] + fn deduplicates_non_adjacent() { + // The old algorithm used Vec::dedup() which only removes adjacent duplicates. + // Using HashSet silently fixed this bug. This test documents the fix. + let tags = extract("#a #b #a"); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].normalized, "a"); + assert_eq!(tags[1].normalized, "b"); + } + + #[test] + fn mid_word_extracted() { + // `text#tag` — `#` not preceded by whitespace is still matched by the + // char-by-char scan (the old algorithm didn't require whitespace before `#`). + // This test documents the authoritative behaviour: mid-word tags ARE extracted. + let tags = extract("text#tag"); + assert_eq!(names(&tags), ["tag"]); + } + + #[test] + fn hash_only_ignored() { + assert!(extract("# lone hash").is_empty()); + } + + #[test] + fn trailing_punctuation_excluded() { + // punctuation after tag terminates the tag, not included + let tags = extract("#rust."); + assert_eq!(names(&tags), ["rust"]); + } + + #[test] + fn underscore_allowed() { + let tags = extract("#hello_world"); + assert_eq!(names(&tags), ["hello_world"]); + } + + #[test] + fn empty_content() { + assert!(extract("").is_empty()); + } + + #[test] + fn order_of_appearance() { + let tags = extract("#b #a #c"); + assert_eq!(names(&tags), ["b", "a", "c"]); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..27901fe --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,9 @@ +pub mod errors; +pub mod events; +pub mod hashtag; +pub mod models; +pub mod ports; +pub mod value_objects; + +#[cfg(any(test, feature = "test-helpers"))] +pub mod testing; diff --git a/crates/domain/src/models/actor_connection_summary.rs b/crates/domain/src/models/actor_connection_summary.rs new file mode 100644 index 0000000..9aec42d --- /dev/null +++ b/crates/domain/src/models/actor_connection_summary.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone)] +pub struct ActorConnectionSummary { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} diff --git a/crates/domain/src/models/api_key.rs b/crates/domain/src/models/api_key.rs new file mode 100644 index 0000000..7a19aa6 --- /dev/null +++ b/crates/domain/src/models/api_key.rs @@ -0,0 +1,11 @@ +use crate::value_objects::{ApiKeyId, UserId}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct ApiKey { + pub id: ApiKeyId, + pub user_id: UserId, + pub key_hash: String, + pub name: String, + pub created_at: DateTime, +} diff --git a/crates/domain/src/models/connection_type.rs b/crates/domain/src/models/connection_type.rs new file mode 100644 index 0000000..78f2e7e --- /dev/null +++ b/crates/domain/src/models/connection_type.rs @@ -0,0 +1,14 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionType { + Followers, + Following, +} + +impl ConnectionType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Followers => "followers", + Self::Following => "following", + } + } +} diff --git a/crates/domain/src/models/feed.rs b/crates/domain/src/models/feed.rs new file mode 100644 index 0000000..cc6bbe8 --- /dev/null +++ b/crates/domain/src/models/feed.rs @@ -0,0 +1,60 @@ +use crate::models::{thought::Thought, user::User}; +use crate::value_objects::UserId; + +#[derive(Debug, Clone)] +pub struct EngagementStats { + pub like_count: i64, + pub boost_count: i64, + pub reply_count: i64, +} + +/// Present only when an authenticated viewer made the request. +/// `liked`/`boosted` are the viewer's interaction state with this thought. +/// `None` means anonymous request or viewer context unavailable. +#[derive(Debug, Clone)] +pub struct ViewerContext { + pub liked: bool, + pub boosted: bool, +} + +#[derive(Debug, Clone)] +pub struct FeedEntry { + pub thought: Thought, + pub author: User, + pub stats: EngagementStats, + pub viewer: Option, +} + +#[derive(Debug, Clone)] +pub struct UserSummary { + pub id: UserId, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub bio: Option, + pub thought_count: i64, + pub follower_count: i64, + pub following_count: i64, +} + +#[derive(Debug, Clone)] +pub struct PageParams { + pub page: u64, + pub per_page: u64, +} +impl PageParams { + pub fn offset(&self) -> i64 { + ((self.page.saturating_sub(1)) * self.per_page) as i64 + } + pub fn limit(&self) -> i64 { + self.per_page as i64 + } +} + +#[derive(Debug, Clone)] +pub struct Paginated { + pub items: Vec, + pub total: i64, + pub page: u64, + pub per_page: u64, +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs new file mode 100644 index 0000000..9b08768 --- /dev/null +++ b/crates/domain/src/models/mod.rs @@ -0,0 +1,12 @@ +pub mod actor_connection_summary; +pub mod api_key; +pub mod connection_type; +pub mod feed; +pub mod notification; +pub mod remote_actor; +pub mod remote_note; +pub mod social; +pub mod tag; +pub mod thought; +pub mod top_friend; +pub mod user; diff --git a/crates/domain/src/models/notification.rs b/crates/domain/src/models/notification.rs new file mode 100644 index 0000000..1fcc344 --- /dev/null +++ b/crates/domain/src/models/notification.rs @@ -0,0 +1,66 @@ +use crate::value_objects::{NotificationId, ThoughtId, UserId}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, PartialEq)] +pub enum NotificationKind { + Like { + thought_id: ThoughtId, + from_user_id: UserId, + }, + Boost { + thought_id: ThoughtId, + from_user_id: UserId, + }, + Reply { + thought_id: ThoughtId, + from_user_id: UserId, + }, + Mention { + thought_id: ThoughtId, + from_user_id: UserId, + }, + Follow { + from_user_id: UserId, + }, +} + +impl NotificationKind { + pub fn from_user_id(&self) -> &UserId { + match self { + Self::Like { from_user_id, .. } => from_user_id, + Self::Boost { from_user_id, .. } => from_user_id, + Self::Reply { from_user_id, .. } => from_user_id, + Self::Mention { from_user_id, .. } => from_user_id, + Self::Follow { from_user_id } => from_user_id, + } + } + + pub fn thought_id(&self) -> Option<&ThoughtId> { + match self { + Self::Like { thought_id, .. } => Some(thought_id), + Self::Boost { thought_id, .. } => Some(thought_id), + Self::Reply { thought_id, .. } => Some(thought_id), + Self::Mention { thought_id, .. } => Some(thought_id), + Self::Follow { .. } => None, + } + } + + pub fn kind_str(&self) -> &'static str { + match self { + Self::Like { .. } => "like", + Self::Boost { .. } => "boost", + Self::Reply { .. } => "reply", + Self::Mention { .. } => "mention", + Self::Follow { .. } => "follow", + } + } +} + +#[derive(Debug, Clone)] +pub struct Notification { + pub id: NotificationId, + pub user_id: UserId, + pub kind: NotificationKind, + pub read: bool, + pub created_at: DateTime, +} diff --git a/crates/domain/src/models/remote_actor.rs b/crates/domain/src/models/remote_actor.rs new file mode 100644 index 0000000..97ab795 --- /dev/null +++ b/crates/domain/src/models/remote_actor.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct RemoteActor { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub followers_url: Option, + pub following_url: Option, + pub attachment: Vec<(String, String)>, + pub last_fetched_at: DateTime, +} diff --git a/crates/domain/src/models/remote_note.rs b/crates/domain/src/models/remote_note.rs new file mode 100644 index 0000000..279d342 --- /dev/null +++ b/crates/domain/src/models/remote_note.rs @@ -0,0 +1,10 @@ +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct RemoteNote { + pub ap_id: String, + pub content: String, + pub published: DateTime, + pub sensitive: bool, + pub content_warning: Option, +} diff --git a/crates/domain/src/models/social.rs b/crates/domain/src/models/social.rs new file mode 100644 index 0000000..bca0c34 --- /dev/null +++ b/crates/domain/src/models/social.rs @@ -0,0 +1,64 @@ +use crate::value_objects::{BoostId, LikeId, ThoughtId, UserId}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct Like { + pub id: LikeId, + pub user_id: UserId, + pub thought_id: ThoughtId, + pub ap_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct Boost { + pub id: BoostId, + pub user_id: UserId, + pub thought_id: ThoughtId, + pub ap_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FollowState { + Pending, + Accepted, + Rejected, +} + +impl FollowState { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + } + } + + pub fn from_db_str(s: &str) -> Result { + match s { + "pending" => Ok(Self::Pending), + "accepted" => Ok(Self::Accepted), + "rejected" => Ok(Self::Rejected), + other => Err(crate::errors::DomainError::Internal(format!( + "unknown follow_state: '{other}'" + ))), + } + } +} + +#[derive(Debug, Clone)] +pub struct Follow { + pub follower_id: UserId, + pub following_id: UserId, + pub state: FollowState, + pub ap_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct Block { + pub blocker_id: UserId, + pub blocked_id: UserId, + pub created_at: DateTime, +} diff --git a/crates/domain/src/models/tag.rs b/crates/domain/src/models/tag.rs new file mode 100644 index 0000000..ccd9b7c --- /dev/null +++ b/crates/domain/src/models/tag.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone)] +pub struct Tag { + pub id: i32, + pub name: String, +} diff --git a/crates/domain/src/models/thought.rs b/crates/domain/src/models/thought.rs new file mode 100644 index 0000000..72c1953 --- /dev/null +++ b/crates/domain/src/models/thought.rs @@ -0,0 +1,72 @@ +use crate::value_objects::{Content, ThoughtId, UserId}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Visibility { + Public, + Followers, + Unlisted, + Direct, +} + +#[derive(Debug, Clone)] +pub struct Thought { + pub id: ThoughtId, + pub user_id: UserId, + pub content: Content, + pub in_reply_to_id: Option, + pub visibility: Visibility, + pub content_warning: Option, + pub sensitive: bool, + pub local: bool, + pub created_at: DateTime, + pub updated_at: Option>, +} + +impl Visibility { + pub fn as_str(&self) -> &'static str { + match self { + Visibility::Public => "public", + Visibility::Followers => "followers", + Visibility::Unlisted => "unlisted", + Visibility::Direct => "direct", + } + } + + pub fn from_db_str(s: &str) -> Result { + match s { + "public" => Ok(Self::Public), + "followers" => Ok(Self::Followers), + "unlisted" => Ok(Self::Unlisted), + "direct" => Ok(Self::Direct), + other => Err(crate::errors::DomainError::Internal(format!( + "unknown visibility: '{other}'" + ))), + } + } +} + +impl Thought { + pub fn new_local( + id: ThoughtId, + user_id: UserId, + content: Content, + in_reply_to_id: Option, + visibility: Visibility, + content_warning: Option, + sensitive: bool, + ) -> Self { + Self { + id, + user_id, + content, + in_reply_to_id, + visibility, + content_warning, + sensitive, + local: true, + created_at: Utc::now(), + updated_at: None, + } + } +} diff --git a/crates/domain/src/models/top_friend.rs b/crates/domain/src/models/top_friend.rs new file mode 100644 index 0000000..8603ee7 --- /dev/null +++ b/crates/domain/src/models/top_friend.rs @@ -0,0 +1,8 @@ +use crate::value_objects::UserId; + +#[derive(Debug, Clone)] +pub struct TopFriend { + pub user_id: UserId, + pub friend_id: UserId, + pub position: i16, +} diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs new file mode 100644 index 0000000..4e61f3c --- /dev/null +++ b/crates/domain/src/models/user.rs @@ -0,0 +1,43 @@ +use crate::value_objects::{Email, PasswordHash, UserId, Username}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct User { + pub id: UserId, + pub username: Username, + pub email: Email, + pub password_hash: PasswordHash, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, + pub local: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl User { + pub fn new_local( + id: UserId, + username: Username, + email: Email, + password_hash: PasswordHash, + ) -> Self { + let now = Utc::now(); + Self { + id, + username, + email, + password_hash, + display_name: None, + bio: None, + avatar_url: None, + header_url: None, + custom_css: None, + local: true, + created_at: now, + updated_at: now, + } + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs new file mode 100644 index 0000000..8395906 --- /dev/null +++ b/crates/domain/src/ports.rs @@ -0,0 +1,411 @@ +use std::collections::HashMap; + +use crate::{ + errors::DomainError, + events::{DomainEvent, EventEnvelope}, + models::{ + api_key::ApiKey, + feed::{EngagementStats, FeedEntry, PageParams, Paginated, UserSummary, ViewerContext}, + notification::Notification, + remote_actor::RemoteActor, + social::{Block, Boost, Follow, FollowState, Like}, + tag::Tag, + thought::Thought, + top_friend::TopFriend, + user::User, + }, + value_objects::{ + ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username, + }, +}; +use async_trait::async_trait; + +pub struct GeneratedToken { + pub token: String, + pub user_id: UserId, +} + +#[async_trait] +pub trait AuthService: Send + Sync { + fn generate_token(&self, user_id: &UserId) -> Result; + fn validate_token(&self, token: &str) -> Result; +} + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, plain: &str) -> Result; + async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait EventPublisher: Send + Sync { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; +} + +pub trait EventConsumer: Send + Sync { + fn consume(&self) -> futures::stream::BoxStream<'_, Result>; +} + +#[async_trait] +pub trait OutboxWriter: Send + Sync { + async fn append(&self, event: &DomainEvent) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait UserReader: Send + Sync { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn find_by_username(&self, username: &Username) -> Result, DomainError>; + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn list_with_stats(&self) -> Result, DomainError>; + async fn count(&self) -> Result; + async fn list_paginated(&self, page: PageParams) -> Result, DomainError>; + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError>; +} + +#[async_trait] +pub trait UserWriter: Send + Sync { + async fn save(&self, user: &User) -> Result<(), DomainError>; + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError>; +} + +/// Combined supertrait — `AppState.users` stays `Arc`. +/// Blanket impl: any type implementing both sub-traits gets `UserRepository` for free. +pub trait UserRepository: UserReader + UserWriter {} +impl UserRepository for T {} + +#[async_trait] +pub trait ThoughtRepository: Send + Sync { + async fn save(&self, thought: &Thought) -> Result<(), DomainError>; + async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError>; + async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>; + async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>; + async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError>; + async fn list_by_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait LikeRepository: Send + Sync { + async fn save(&self, like: &Like) -> Result<(), DomainError>; + async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError>; + async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; +} + +#[async_trait] +pub trait BoostRepository: Send + Sync { + async fn save(&self, boost: &Boost) -> Result<(), DomainError>; + async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError>; + async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; +} + +#[async_trait] +pub trait EngagementRepository: Send + Sync { + async fn get_for_thoughts( + &self, + thought_ids: &[ThoughtId], + viewer_id: Option<&UserId>, + ) -> Result)>, DomainError>; +} + +#[async_trait] +pub trait FollowRepository: Send + Sync { + async fn save(&self, follow: &Follow) -> Result<(), DomainError>; + async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>; + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError>; + async fn update_state( + &self, + follower_id: &UserId, + following_id: &UserId, + state: &FollowState, + ) -> Result<(), DomainError>; + async fn list_followers( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + async fn list_following( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + async fn get_accepted_following_ids( + &self, + user_id: &UserId, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait BlockRepository: Send + Sync { + async fn save(&self, block: &Block) -> Result<(), DomainError>; + async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError>; + async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result; +} + +#[async_trait] +pub trait TagRepository: Send + Sync { + async fn find_or_create(&self, name: &str) -> Result; + async fn attach_to_thought( + &self, + thought_id: &ThoughtId, + tag_id: i32, + ) -> Result<(), DomainError>; + async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>; + async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError>; + async fn list_thoughts_by_tag( + &self, + tag_name: &str, + page: &PageParams, + ) -> Result, DomainError>; + /// Returns (tag_name, thought_count) pairs ordered by usage, most popular first. + async fn popular_tags(&self, limit: usize) -> Result, DomainError>; +} + +#[async_trait] +pub trait ApiKeyRepository: Send + Sync { + async fn save(&self, key: &ApiKey) -> Result<(), DomainError>; + async fn find_by_hash(&self, key_hash: &str) -> Result, DomainError>; + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; + async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait ApiKeyService: Send + Sync { + async fn validate_key(&self, raw_key: &str) -> Result, DomainError>; +} + +#[async_trait] +pub trait TopFriendRepository: Send + Sync { + async fn set_top_friends( + &self, + user_id: &UserId, + friends: Vec<(UserId, i16)>, + ) -> Result<(), DomainError>; + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; +} + +#[async_trait] +pub trait NotificationRepository: Send + Sync { + async fn save(&self, n: &Notification) -> Result<(), DomainError>; + async fn list_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + async fn count_unread(&self, user_id: &UserId) -> Result; + async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>; + async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait RemoteActorRepository: Send + Sync { + async fn upsert(&self, actor: &RemoteActor) -> Result<(), DomainError>; + async fn find_by_url(&self, url: &str) -> Result, DomainError>; +} + +#[async_trait] +pub trait RemoteActorConnectionRepository: Send + Sync { + async fn upsert_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], + ) -> Result<(), DomainError>; + + async fn list_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result, DomainError>; + + async fn connection_page_age( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result>, DomainError>; +} + +#[async_trait] +pub trait FederationLookupPort: Send + Sync { + async fn lookup_actor(&self, handle: &str) -> Result; + async fn actor_json(&self, user_id: &UserId) -> Result; + async fn followers_collection_json( + &self, + user_id: &UserId, + page: Option, + ) -> Result; + async fn following_collection_json( + &self, + user_id: &UserId, + page: Option, + ) -> Result; +} + +#[async_trait] +pub trait FederationFollowPort: Send + Sync { + async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; + async fn unfollow_remote( + &self, + local_user_id: &UserId, + handle: &str, + ) -> Result<(), DomainError>; + async fn get_remote_following(&self, user_id: &UserId) + -> Result, DomainError>; +} + +#[async_trait] +pub trait FederationFollowRequestPort: Send + Sync { + async fn get_pending_followers( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + async fn accept_follow_request( + &self, + user_id: &UserId, + actor_url: &str, + ) -> Result<(), DomainError>; + async fn reject_follow_request( + &self, + user_id: &UserId, + actor_url: &str, + ) -> Result<(), DomainError>; + async fn get_remote_followers(&self, user_id: &UserId) + -> Result, DomainError>; + async fn remove_remote_follower( + &self, + user_id: &UserId, + actor_url: &str, + ) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait FederationFetchPort: Send + Sync { + async fn fetch_outbox_page( + &self, + outbox_url: &str, + page: u32, + ) -> Result, DomainError>; + async fn fetch_actor_urls_from_collection( + &self, + collection_url: &str, + ) -> Result, DomainError>; + async fn resolve_actor_profiles( + &self, + urls: Vec, + ) -> Vec; +} + +pub trait FederationActionPort: + FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort +{ +} +impl< + T: FederationLookupPort + + FederationFollowPort + + FederationFollowRequestPort + + FederationFetchPort, + > FederationActionPort for T +{ +} + +#[derive(Debug, Clone)] +pub enum FeedScope { + Home { following_ids: Vec }, + Public, + Tag { tag_name: String }, + User { user_id: UserId }, + Search { query: String }, +} + +#[derive(Debug, Clone)] +pub struct FeedQuery { + pub scope: FeedScope, + pub page: PageParams, + pub viewer_id: Option, +} + +impl FeedQuery { + pub fn home(viewer_id: UserId, following_ids: Vec, page: PageParams) -> Self { + Self { scope: FeedScope::Home { following_ids }, page, viewer_id: Some(viewer_id) } + } + pub fn public(page: PageParams, viewer_id: Option) -> Self { + Self { scope: FeedScope::Public, page, viewer_id } + } + pub fn tag(tag_name: impl Into, page: PageParams, viewer_id: Option) -> Self { + Self { scope: FeedScope::Tag { tag_name: tag_name.into() }, page, viewer_id } + } + pub fn user(user_id: UserId, page: PageParams, viewer_id: Option) -> Self { + Self { scope: FeedScope::User { user_id }, page, viewer_id } + } + pub fn search(query: impl Into, page: PageParams, viewer_id: Option) -> Self { + Self { scope: FeedScope::Search { query: query.into() }, page, viewer_id } + } +} + +#[async_trait] +pub trait FeedRepository: Send + Sync { + async fn query(&self, q: &FeedQuery) -> Result, DomainError>; +} + +#[async_trait] +pub trait SearchPort: Send + Sync { + /// Full-text search over public thoughts, ranked by trigram similarity. + async fn search_thoughts( + &self, + query: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; + + /// Search users by username or display_name, ranked by trigram similarity. + async fn search_users( + &self, + query: &str, + page: &PageParams, + ) -> Result, DomainError>; +} + + +#[async_trait] +pub trait FederationSchedulerPort: Send + Sync { + async fn schedule_actor_posts_fetch( + &self, + actor_ap_url: &str, + outbox_url: &str, + ) -> Result<(), DomainError>; + + async fn schedule_connections_fetch( + &self, + actor_ap_url: &str, + collection_url: &str, + connection_type: &str, + page: u32, + ) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs new file mode 100644 index 0000000..11193f5 --- /dev/null +++ b/crates/domain/src/testing.rs @@ -0,0 +1,964 @@ +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + api_key::ApiKey, + feed::{FeedEntry, PageParams, Paginated, UserSummary}, + notification::Notification, + remote_actor::RemoteActor, + social::{Block, Boost, Follow, FollowState, Like}, + tag::Tag, + thought::Thought, + top_friend::TopFriend, + user::User, + }, + ports::*, + value_objects::{ + ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username, + }, +}; +use async_trait::async_trait; +use chrono::Utc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Default, Clone)] +pub struct TestStore { + pub users: Arc>>, + pub thoughts: Arc>>, + pub likes: Arc>>, + pub boosts: Arc>>, + pub follows: Arc>>, + pub blocks: Arc>>, + pub tags: Arc>>, + pub api_keys: Arc>>, + pub top_friends: Arc>>, + pub notifications: Arc>>, + pub events: Arc>>, + /// AP URL → UserId for remote actors (used by find_remote_actor_id / intern_remote_actor) + pub actor_ap_ids: Arc>>, + /// ThoughtId → AP object URL (used by get_thought_ap_id) + pub thought_ap_ids: Arc>>, +} + +#[async_trait] +impl UserReader for TestStore { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| &u.id == id) + .cloned()) + } + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| u.username.as_str() == username.as_str()) + .cloned()) + } + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| u.email.as_str() == email.as_str()) + .cloned()) + } + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } + async fn count(&self) -> Result { + Ok(self + .users + .lock() + .unwrap() + .iter() + .filter(|u| u.local) + .count() as i64) + } + + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + let all = self.list_with_stats().await?; + let total = all.len() as i64; + let start = page.offset() as usize; + let items: Vec = all.into_iter().skip(start).take(page.limit() as usize).collect(); + Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) + } + + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + let g = self.users.lock().unwrap(); + let map = g.iter() + .filter(|u| ids.contains(&u.id)) + .map(|u| (u.id.clone(), u.clone())) + .collect(); + Ok(map) + } +} + +#[async_trait] +impl UserWriter for TestStore { + async fn save(&self, user: &User) -> Result<(), DomainError> { + let mut g = self.users.lock().unwrap(); + g.retain(|u| u.id != user.id); + g.push(user.clone()); + Ok(()) + } + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + if let Some(u) = self + .users + .lock() + .unwrap() + .iter_mut() + .find(|u| &u.id == user_id) + { + u.display_name = display_name; + u.bio = bio; + u.avatar_url = avatar_url; + u.header_url = header_url; + u.custom_css = custom_css; + } + Ok(()) + } +} + +#[async_trait] +impl ThoughtRepository for TestStore { + async fn save(&self, t: &Thought) -> Result<(), DomainError> { + let mut g = self.thoughts.lock().unwrap(); + g.retain(|x| x.id != t.id); + g.push(t.clone()); + Ok(()) + } + async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { + Ok(self + .thoughts + .lock() + .unwrap() + .iter() + .find(|t| &t.id == id) + .cloned()) + } + async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { + let mut g = self.thoughts.lock().unwrap(); + let before = g.len(); + g.retain(|t| !(&t.id == id && &t.user_id == user_id)); + if g.len() == before { + return Err(DomainError::NotFound); + } + Ok(()) + } + async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { + if let Some(t) = self + .thoughts + .lock() + .unwrap() + .iter_mut() + .find(|t| &t.id == id) + { + t.content = content.clone(); + t.updated_at = Some(Utc::now()); + } + Ok(()) + } + async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { + Ok(self + .thoughts + .lock() + .unwrap() + .iter() + .filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id) + .cloned() + .collect()) + } + async fn list_by_user( + &self, + _user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } +} + +#[async_trait] +impl LikeRepository for TestStore { + async fn save(&self, like: &Like) -> Result<(), DomainError> { + let mut g = self.likes.lock().unwrap(); + if g.iter() + .any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) + { + return Err(DomainError::Conflict("already liked".into())); + } + g.push(like.clone()); + Ok(()) + } + async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { + let mut g = self.likes.lock().unwrap(); + let before = g.len(); + g.retain(|l| !(&l.user_id == user_id && &l.thought_id == thought_id)); + if g.len() == before { + return Err(DomainError::NotFound); + } + Ok(()) + } + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(self + .likes + .lock() + .unwrap() + .iter() + .find(|l| &l.user_id == user_id && &l.thought_id == thought_id) + .cloned()) + } + async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { + Ok(self + .likes + .lock() + .unwrap() + .iter() + .filter(|l| &l.thought_id == thought_id) + .count() as i64) + } +} + +#[async_trait] +impl BoostRepository for TestStore { + async fn save(&self, boost: &Boost) -> Result<(), DomainError> { + let mut g = self.boosts.lock().unwrap(); + if g.iter() + .any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) + { + return Err(DomainError::Conflict("already boosted".into())); + } + g.push(boost.clone()); + Ok(()) + } + async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { + let mut g = self.boosts.lock().unwrap(); + let before = g.len(); + g.retain(|b| !(&b.user_id == user_id && &b.thought_id == thought_id)); + if g.len() == before { + return Err(DomainError::NotFound); + } + Ok(()) + } + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(self + .boosts + .lock() + .unwrap() + .iter() + .find(|b| &b.user_id == user_id && &b.thought_id == thought_id) + .cloned()) + } + async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { + Ok(self + .boosts + .lock() + .unwrap() + .iter() + .filter(|b| &b.thought_id == thought_id) + .count() as i64) + } +} + +#[async_trait] +impl EngagementRepository for TestStore { + async fn get_for_thoughts( + &self, + thought_ids: &[ThoughtId], + viewer_id: Option<&UserId>, + ) -> Result)>, DomainError> { + use crate::models::feed::{EngagementStats, ViewerContext}; + let likes = self.likes.lock().unwrap(); + let boosts = self.boosts.lock().unwrap(); + let thoughts = self.thoughts.lock().unwrap(); + + let mut result = HashMap::new(); + for tid in thought_ids { + let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64; + let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64; + let reply_count = thoughts.iter().filter(|t| t.in_reply_to_id.as_ref() == Some(tid)).count() as i64; + let viewer = viewer_id.map(|vid| ViewerContext { + liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid), + boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid), + }); + result.insert(tid.clone(), (EngagementStats { like_count, boost_count, reply_count }, viewer)); + } + Ok(result) + } +} + +#[async_trait] +impl FollowRepository for TestStore { + async fn save(&self, follow: &Follow) -> Result<(), DomainError> { + let mut g = self.follows.lock().unwrap(); + g.retain(|f| { + !(f.follower_id == follow.follower_id && f.following_id == follow.following_id) + }); + g.push(follow.clone()); + Ok(()) + } + async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { + let mut g = self.follows.lock().unwrap(); + let before = g.len(); + g.retain(|f| !(&f.follower_id == follower_id && &f.following_id == following_id)); + if g.len() == before { + return Err(DomainError::NotFound); + } + Ok(()) + } + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .find(|f| &f.follower_id == follower_id && &f.following_id == following_id) + .cloned()) + } + async fn update_state( + &self, + follower_id: &UserId, + following_id: &UserId, + state: &FollowState, + ) -> Result<(), DomainError> { + if let Some(f) = self + .follows + .lock() + .unwrap() + .iter_mut() + .find(|f| &f.follower_id == follower_id && &f.following_id == following_id) + { + f.state = state.clone(); + } + Ok(()) + } + async fn list_followers( + &self, + _user_id: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn list_following( + &self, + _user_id: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn get_accepted_following_ids( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted) + .map(|f| f.following_id.clone()) + .collect()) + } +} + +#[async_trait] +impl BlockRepository for TestStore { + async fn save(&self, block: &Block) -> Result<(), DomainError> { + self.blocks.lock().unwrap().push(block.clone()); + Ok(()) + } + async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { + self.blocks + .lock() + .unwrap() + .retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id)); + Ok(()) + } + async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { + Ok(self + .blocks + .lock() + .unwrap() + .iter() + .any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id)) + } +} + +#[async_trait] +impl TagRepository for TestStore { + async fn find_or_create(&self, name: &str) -> Result { + let mut g = self.tags.lock().unwrap(); + if let Some(t) = g.iter().find(|t| t.name == name) { + return Ok(t.clone()); + } + let tag = Tag { + id: g.len() as i32 + 1, + name: name.to_string(), + }; + g.push(tag.clone()); + Ok(tag) + } + async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { + Ok(()) + } + async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { + Ok(()) + } + async fn list_for_thought(&self, _tid: &ThoughtId) -> Result, DomainError> { + Ok(vec![]) + } + async fn list_thoughts_by_tag( + &self, + _name: &str, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn popular_tags(&self, _limit: usize) -> Result, DomainError> { + Ok(vec![]) + } +} + +#[async_trait] +impl ApiKeyRepository for TestStore { + async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { + self.api_keys.lock().unwrap().push(key.clone()); + Ok(()) + } + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + Ok(self + .api_keys + .lock() + .unwrap() + .iter() + .find(|k| k.key_hash == hash) + .cloned()) + } + async fn list_for_user(&self, uid: &UserId) -> Result, DomainError> { + Ok(self + .api_keys + .lock() + .unwrap() + .iter() + .filter(|k| &k.user_id == uid) + .cloned() + .collect()) + } + async fn delete(&self, id: &ApiKeyId, uid: &UserId) -> Result<(), DomainError> { + self.api_keys + .lock() + .unwrap() + .retain(|k| !(&k.id == id && &k.user_id == uid)); + Ok(()) + } +} + +#[async_trait] +impl ApiKeyService for TestStore { + async fn validate_key(&self, raw_key: &str) -> Result, DomainError> { + use sha2::{Digest, Sha256}; + let hash = hex::encode(Sha256::digest(raw_key.as_bytes())); + Ok(self + .api_keys + .lock() + .unwrap() + .iter() + .find(|k| k.key_hash == hash) + .map(|k| k.user_id.clone())) + } +} + +#[async_trait] +impl TopFriendRepository for TestStore { + async fn set_top_friends( + &self, + user_id: &UserId, + friends: Vec<(UserId, i16)>, + ) -> Result<(), DomainError> { + let mut g = self.top_friends.lock().unwrap(); + g.retain(|tf| &tf.user_id != user_id); + for (fid, pos) in friends { + g.push(TopFriend { + user_id: user_id.clone(), + friend_id: fid, + position: pos, + }); + } + Ok(()) + } + async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { + Ok(vec![]) + } +} + +#[async_trait] +impl NotificationRepository for TestStore { + async fn save(&self, n: &Notification) -> Result<(), DomainError> { + self.notifications.lock().unwrap().push(n.clone()); + Ok(()) + } + async fn list_for_user( + &self, + uid: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + let items: Vec<_> = self + .notifications + .lock() + .unwrap() + .iter() + .filter(|n| &n.user_id == uid) + .cloned() + .collect(); + let total = items.len() as i64; + Ok(Paginated { + items, + total, + page: 1, + per_page: 20, + }) + } + async fn count_unread(&self, uid: &UserId) -> Result { + Ok(self + .notifications + .lock() + .unwrap() + .iter() + .filter(|n| &n.user_id == uid && !n.read) + .count() as u64) + } + async fn mark_read(&self, id: &NotificationId, _uid: &UserId) -> Result<(), DomainError> { + if let Some(n) = self + .notifications + .lock() + .unwrap() + .iter_mut() + .find(|n| &n.id == id) + { + n.read = true; + } + Ok(()) + } + async fn mark_all_read(&self, uid: &UserId) -> Result<(), DomainError> { + for n in self + .notifications + .lock() + .unwrap() + .iter_mut() + .filter(|n| &n.user_id == uid) + { + n.read = true; + } + Ok(()) + } +} + +#[async_trait] +impl RemoteActorRepository for TestStore { + async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { + Ok(()) + } + async fn find_by_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) + } +} + +#[async_trait] +impl FederationLookupPort for TestStore { + async fn lookup_actor(&self, _handle: &str) -> Result { + Err(DomainError::NotFound) + } + + async fn actor_json(&self, _user_id: &UserId) -> Result { + Err(DomainError::NotFound) + } + + async fn followers_collection_json( + &self, + _user_id: &UserId, + _page: Option, + ) -> Result { + Err(DomainError::NotFound) + } + + async fn following_collection_json( + &self, + _user_id: &UserId, + _page: Option, + ) -> Result { + Err(DomainError::NotFound) + } +} + +#[async_trait] +impl FederationFollowPort for TestStore { + async fn follow_remote( + &self, + _local_user_id: &UserId, + _handle: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn unfollow_remote( + &self, + _local_user_id: &UserId, + _handle: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn get_remote_following( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } +} + +#[async_trait] +impl FederationFollowRequestPort for TestStore { + async fn get_pending_followers( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + + async fn accept_follow_request( + &self, + _user_id: &UserId, + _actor_url: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn reject_follow_request( + &self, + _user_id: &UserId, + _actor_url: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn get_remote_followers( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + + async fn remove_remote_follower( + &self, + _user_id: &UserId, + _actor_url: &str, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +#[async_trait] +impl FederationFetchPort for TestStore { + async fn fetch_outbox_page( + &self, + _outbox_url: &str, + _page: u32, + ) -> Result, DomainError> { + Ok(vec![]) + } + + async fn fetch_actor_urls_from_collection( + &self, + _collection_url: &str, + ) -> Result, DomainError> { + Ok(vec![]) + } + + async fn resolve_actor_profiles( + &self, + _urls: Vec, + ) -> Vec { + vec![] + } +} + +#[async_trait] +impl RemoteActorConnectionRepository for TestStore { + async fn upsert_connections( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + _actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn list_connections( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + ) -> Result, DomainError> + { + Ok(vec![]) + } + + async fn connection_page_age( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + ) -> Result>, DomainError> { + Ok(None) + } +} + +#[async_trait] +impl FeedRepository for TestStore { + async fn query(&self, _q: &crate::ports::FeedQuery) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } +} + +#[async_trait] +impl SearchPort for TestStore { + async fn search_thoughts( + &self, + _q: &str, + _p: &PageParams, + _v: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn search_users( + &self, + _q: &str, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } +} + + +#[async_trait] +impl FederationSchedulerPort for TestStore { + async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn schedule_connections_fetch( + &self, + _: &str, + _: &str, + _: &str, + _: u32, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +#[async_trait] +impl EventPublisher for TestStore { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +pub struct NoOpEventPublisher; +#[async_trait] +impl EventPublisher for NoOpEventPublisher { + async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } +} + +#[derive(Default, Clone)] +pub struct TestOutbox { + pub entries: Arc>>, +} + +impl TestOutbox { + pub fn staged(&self) -> Vec { + self.entries.lock().unwrap().clone() + } +} + +#[async_trait] +impl OutboxWriter for TestOutbox { + async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.entries.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +pub struct NoOpOutboxWriter; +#[async_trait] +impl OutboxWriter for NoOpOutboxWriter { + async fn append(&self, _e: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } +} + +#[cfg(test)] +mod federation_port_tests { + use super::*; + use crate::value_objects::UserId; + + fn uid() -> UserId { + UserId::new() + } + + #[tokio::test] + async fn test_store_lookup_returns_not_found() { + let store = TestStore::default(); + let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn test_store_follow_remote_is_noop_ok() { + let store = TestStore::default(); + store + .follow_remote(&uid(), "@alice@example.com") + .await + .unwrap(); + } + + #[tokio::test] + async fn test_store_actor_json_returns_not_found() { + let store = TestStore::default(); + let err = store.actor_json(&UserId::new()).await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn test_store_fetch_outbox_returns_empty() { + let store = TestStore::default(); + let notes = store + .fetch_outbox_page("https://example.com/outbox", 1) + .await + .unwrap(); + assert!(notes.is_empty()); + } + + #[tokio::test] + async fn test_store_resolve_actor_profiles_returns_empty() { + let store = TestStore::default(); + let result = store + .resolve_actor_profiles(vec!["https://example.com/users/alice".into()]) + .await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn test_store_fetch_collection_urls_returns_empty() { + let store = TestStore::default(); + let urls = store + .fetch_actor_urls_from_collection("https://example.com/users/alice/followers") + .await + .unwrap(); + assert!(urls.is_empty()); + } +} + +#[cfg(test)] +mod search_tests { + use super::*; + use crate::models::feed::PageParams; + + #[tokio::test] + async fn test_store_search_thoughts_returns_empty() { + let store = TestStore::default(); + let result = store + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(result.total, 0); + } + + #[tokio::test] + async fn test_store_search_users_returns_empty() { + let store = TestStore::default(); + let result = store + .search_users( + "alice", + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(result.total, 0); + } +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs new file mode 100644 index 0000000..7d3f3a8 --- /dev/null +++ b/crates/domain/src/value_objects.rs @@ -0,0 +1,150 @@ +use crate::errors::DomainError; +use uuid::Uuid; + +const MAX_USERNAME_LENGTH: usize = 32; +const MAX_EMAIL_LENGTH: usize = 255; +const MAX_CONTENT_LENGTH: usize = 128; + +macro_rules! uuid_id { + ($name:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + pub struct $name(Uuid); + impl $name { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn from_uuid(u: Uuid) -> Self { + Self(u) + } + pub fn as_uuid(&self) -> Uuid { + self.0 + } + } + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +uuid_id!(UserId); +uuid_id!(ThoughtId); +uuid_id!(LikeId); +uuid_id!(BoostId); +uuid_id!(ApiKeyId); +uuid_id!(NotificationId); + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Username(String); +impl Username { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + if s.is_empty() || s.len() > MAX_USERNAME_LENGTH { + return Err(DomainError::InvalidInput("username: 1-32 chars".into())); + } + if !s + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + { + return Err(DomainError::InvalidInput( + "username: alphanumeric, underscore, or dot only".into(), + )); + } + Ok(Self(s)) + } + pub fn from_trusted(s: String) -> Self { + Self(s) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl std::fmt::Display for Username { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Email(String); +impl Email { + pub fn new(s: impl Into) -> Result { + let s = s.into().to_lowercase(); + if !s.contains('@') || s.len() > MAX_EMAIL_LENGTH { + return Err(DomainError::InvalidInput("invalid email".into())); + } + Ok(Self(s)) + } + pub fn from_trusted(s: String) -> Self { + Self(s) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PasswordHash(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Content(String); +impl Content { + pub fn new_local(s: impl Into) -> Result { + let s = s.into(); + if s.is_empty() || s.len() > MAX_CONTENT_LENGTH { + return Err(DomainError::InvalidInput("content: 1-128 chars".into())); + } + Ok(Self(s)) + } + pub fn new_remote(s: impl Into) -> Self { + Self(s.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl std::fmt::Display for Content { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn username_rejects_empty() { + assert!(Username::new("").is_err()); + } + #[test] + fn username_rejects_too_long() { + assert!(Username::new("a".repeat(33)).is_err()); + } + #[test] + fn username_rejects_invalid_chars() { + assert!(Username::new("hello world").is_err()); + } + #[test] + fn username_accepts_valid() { + assert!(Username::new("hello_123").is_ok()); + } + #[test] + fn content_local_rejects_over_128() { + assert!(Content::new_local("a".repeat(129)).is_err()); + } + #[test] + fn content_local_accepts_128() { + assert!(Content::new_local("a".repeat(128)).is_ok()); + } + #[test] + fn email_rejects_no_at() { + assert!(Email::new("notanemail").is_err()); + } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..b73bc14 --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "presentation" +version = "0.1.0" +edition = "2021" + +[dependencies] +domain = { workspace = true } +activitypub-base = { workspace = true } +application = { workspace = true } +api-types = { workspace = true } +axum = { workspace = true } +tower-http = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +url = { workspace = true } +utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } +utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } + +[dev-dependencies] +http-body-util = "0.1" +tower = "0.5" +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs new file mode 100644 index 0000000..f0c1e38 --- /dev/null +++ b/crates/presentation/src/errors.rs @@ -0,0 +1,46 @@ +use api_types::responses::ErrorResponse; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use domain::errors::DomainError; + +pub enum ApiError { + Domain(DomainError), + Unauthorized, + BadRequest(String), +} + +impl From for ApiError { + fn from(e: DomainError) -> Self { + Self::Domain(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, msg) = match self { + Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()), + Self::Domain(DomainError::Unauthorized) => { + (StatusCode::UNAUTHORIZED, "unauthorized".into()) + } + Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()), + Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m), + Self::Domain(DomainError::UniqueViolation { field }) => { + (StatusCode::CONFLICT, format!("{field} already taken")) + } + Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m), + Self::Domain(DomainError::ExternalService(_)) => { + (StatusCode::BAD_GATEWAY, "external service error".into()) + } + Self::Domain(DomainError::Internal(_)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".into(), + ), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), + Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + }; + (status, Json(ErrorResponse { error: msg })).into_response() + } +} diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs new file mode 100644 index 0000000..7cc7de7 --- /dev/null +++ b/crates/presentation/src/extractors.rs @@ -0,0 +1,93 @@ +use crate::{errors::ApiError, state::AppState}; +use axum::{extract::FromRequestParts, http::request::Parts}; +use domain::value_objects::UserId; + +// --------------------------------------------------------------------------- +// deps_struct! — generates Deps struct + impl FromAppState from a field list. +// Field names must match AppState exactly (enforced at compile time). +// --------------------------------------------------------------------------- + +#[macro_export] +macro_rules! deps_struct { + ( $name:ident { $( $field:ident : $trait:path ),+ $(,)? } ) => { + pub struct $name { + $( pub $field: ::std::sync::Arc, )+ + } + impl $crate::extractors::FromAppState for $name { + fn from_state(s: &$crate::state::AppState) -> Self { + Self { + $( $field: ::std::sync::Arc::clone(&s.$field), )+ + } + } + } + }; +} + +// --------------------------------------------------------------------------- +// Deps extractor — narrows AppState to a handler-specific deps struct +// --------------------------------------------------------------------------- + +pub struct Deps(pub S); + +pub trait FromAppState: Sized { + fn from_state(s: &AppState) -> Self; +} + +impl FromRequestParts for Deps { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + _parts: &mut Parts, + state: &AppState, + ) -> Result { + Ok(Deps(S::from_state(state))) + } +} + +// --------------------------------------------------------------------------- +// Auth extractors +// --------------------------------------------------------------------------- + +pub struct AuthUser(pub UserId); +pub struct OptionalAuthUser(pub Option); + +impl FromRequestParts for AuthUser { + type Rejection = ApiError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + extract_user_id(parts, state) + .await? + .ok_or(ApiError::Unauthorized) + .map(AuthUser) + } +} + +impl FromRequestParts for OptionalAuthUser { + type Rejection = ApiError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + Ok(OptionalAuthUser(extract_user_id(parts, state).await?)) + } +} + +async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result, ApiError> { + if let Some(auth_header) = parts.headers.get("Authorization") { + if let Ok(s) = auth_header.to_str() { + if let Some(token) = s.strip_prefix("Bearer ") { + return state + .auth + .validate_token(token) + .map(Some) + .map_err(|_| ApiError::Unauthorized); + } + } + } + if let Some(key_header) = parts.headers.get("X-Api-Key") { + if let Ok(raw) = key_header.to_str() { + return state + .api_key_auth + .validate_key(raw) + .await + .map_err(|_| ApiError::Unauthorized); + } + } + Ok(None) +} diff --git a/crates/presentation/src/handlers/api_keys.rs b/crates/presentation/src/handlers/api_keys.rs new file mode 100644 index 0000000..983b97c --- /dev/null +++ b/crates/presentation/src/handlers/api_keys.rs @@ -0,0 +1,58 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps}, +}; +use api_types::{ + requests::CreateApiKeyRequest, + responses::{ApiKeyResponse, CreatedApiKeyResponse}, +}; +use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys}; +use axum::{ + extract::Path, + http::StatusCode, + Json, +}; +use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId}; +use uuid::Uuid; + +deps_struct!(ApiKeysDeps { + api_keys: ApiKeyRepository, +}); + +#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec)), security(("bearer_auth" = [])))] +pub async fn get_api_keys( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result>, ApiError> { + let keys = list_api_keys(&*d.api_keys, &uid).await?; + Ok(Json( + keys.into_iter() + .map(|k| ApiKeyResponse { + id: k.id.as_uuid(), + name: k.name, + created_at: k.created_at, + }) + .collect(), + )) +} +#[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))] +pub async fn post_api_key( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result, ApiError> { + let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?; + Ok(Json( + serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }), + )) +} +#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))] +pub async fn delete_api_key_handler( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + delete_api_key(&*d.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs new file mode 100644 index 0000000..ed66332 --- /dev/null +++ b/crates/presentation/src/handlers/auth.rs @@ -0,0 +1,94 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::Deps, +}; +use api_types::{ + requests::{LoginRequest, RegisterRequest}, + responses::{AuthResponse, ErrorResponse, UserResponse}, +}; +use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; +use axum::{http::StatusCode, response::IntoResponse, Json}; +use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}; + +deps_struct!(AuthDeps { + users: UserRepository, + hasher: PasswordHasher, + auth: AuthService, + events: EventPublisher, +}); + +pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { + UserResponse { + id: u.id.as_uuid(), + username: u.username.to_string(), + display_name: u.display_name.clone(), + bio: u.bio.clone(), + avatar_url: u.avatar_url.clone(), + header_url: u.header_url.clone(), + custom_css: u.custom_css.clone(), + local: u.local, + is_followed_by_viewer: false, + created_at: u.created_at, + } +} + +#[utoipa::path( + post, path = "/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered", body = AuthResponse), + (status = 409, description = "Username or email taken", body = ErrorResponse), + (status = 422, description = "Invalid input", body = ErrorResponse), + ) +)] +pub async fn post_register( + Deps(d): Deps, + Json(body): Json, +) -> Result { + let out = register( + &*d.users, + &*d.hasher, + &*d.auth, + &*d.events, + RegisterInput { + username: body.username, + email: body.email, + password: body.password, + }, + ) + .await?; + let resp = AuthResponse { + token: out.token, + user: to_user_response(&out.user), + }; + Ok((StatusCode::CREATED, Json(resp))) +} + +#[utoipa::path( + post, path = "/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = AuthResponse), + (status = 401, description = "Invalid credentials", body = ErrorResponse), + ) +)] +pub async fn post_login( + Deps(d): Deps, + Json(body): Json, +) -> Result { + let out = login( + &*d.users, + &*d.hasher, + &*d.auth, + LoginInput { + email: body.email, + password: body.password, + }, + ) + .await?; + Ok(Json(AuthResponse { + token: out.token, + user: to_user_response(&out.user), + })) +} diff --git a/crates/presentation/src/handlers/federation_actors.rs b/crates/presentation/src/handlers/federation_actors.rs new file mode 100644 index 0000000..8d15177 --- /dev/null +++ b/crates/presentation/src/handlers/federation_actors.rs @@ -0,0 +1,151 @@ +use crate::{ + errors::ApiError, + extractors::{Deps, FromAppState, OptionalAuthUser}, + handlers::feed::to_thought_response, + state::AppState, +}; +use api_types::{ + requests::PaginationQuery, + responses::{ActorConnectionPageResponse, ActorConnectionResponse}, +}; +use application::use_cases::federation_management::{ + get_actor_connections_page, get_remote_actor_posts, +}; +use axum::{ + extract::{Path, Query}, + Json, +}; +use activitypub_base::ActivityPubRepository; +use domain::{ + models::feed::PageParams, + ports::{ + FederationActionPort, FederationSchedulerPort, FeedRepository, + RemoteActorConnectionRepository, + }, +}; +use std::sync::Arc; + +pub struct FederationActorsDeps { + pub federation: Arc, + pub ap_repo: Arc, + pub feed: Arc, + pub federation_scheduler: Arc, + pub remote_actor_connections: Arc, +} + +impl FromAppState for FederationActorsDeps { + fn from_state(s: &AppState) -> Self { + Self { + federation: s.federation.clone(), + ap_repo: s.ap_repo.clone(), + feed: s.feed.clone(), + federation_scheduler: s.federation_scheduler.clone(), + remote_actor_connections: s.remote_actor_connections.clone(), + } + } +} + +pub async fn remote_actor_posts_handler( + Deps(d): Deps, + Path(handle): Path, + Query(q): Query, + OptionalAuthUser(viewer): OptionalAuthUser, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = get_remote_actor_posts( + &*d.federation, + &*d.ap_repo, + &*d.feed, + &*d.federation_scheduler, + &handle, + page, + viewer.as_ref(), + ) + .await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} + +pub async fn actor_followers_handler( + Deps(d): Deps, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(d, handle, "followers", q.page() as u32).await +} + +pub async fn actor_following_handler( + Deps(d): Deps, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(d, handle, "following", q.page() as u32).await +} + +async fn actor_connections_handler( + d: FederationActorsDeps, + handle: String, + connection_type: &str, + page: u32, +) -> Result, ApiError> { + let (items, has_more) = get_actor_connections_page( + &*d.federation, + &*d.remote_actor_connections, + &*d.federation_scheduler, + &handle, + connection_type, + page, + ) + .await?; + Ok(Json(ActorConnectionPageResponse { + items: items + .into_iter() + .map(|a| ActorConnectionResponse { + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + url: a.url, + }) + .collect(), + page, + has_more, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::make_state; + use axum::{body::Body, http::Request, routing::get, Router}; + use tower::ServiceExt; + + fn app() -> Router { + Router::new() + .route( + "/federation/actors/{handle}/posts", + get(remote_actor_posts_handler), + ) + .with_state(make_state()) + } + + #[tokio::test] + async fn unknown_actor_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/federation/actors/%40alice%40example.com/posts") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } +} diff --git a/crates/presentation/src/handlers/federation_management.rs b/crates/presentation/src/handlers/federation_management.rs new file mode 100644 index 0000000..f090c2f --- /dev/null +++ b/crates/presentation/src/handlers/federation_management.rs @@ -0,0 +1,109 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps}, +}; +use api_types::responses::{ProfileField, RemoteActorResponse}; +use application::use_cases::federation_management::{ + accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following, + reject_follow_request, remove_remote_following, +}; +use axum::{http::StatusCode, Json}; +use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ActorUrlBody { + pub actor_url: String, +} + +#[derive(Deserialize)] +pub struct HandleBody { + pub handle: String, +} + +deps_struct!(FederationManagementDeps { + federation: FederationActionPort, + follows: FollowRepository, + users: UserRepository, + events: EventPublisher, +}); + +fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse { + RemoteActorResponse { + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + url: a.url, + bio: a.bio, + banner_url: a.banner_url, + also_known_as: a.also_known_as, + outbox_url: a.outbox_url, + followers_url: a.followers_url, + following_url: a.following_url, + attachment: a + .attachment + .into_iter() + .map(|(name, value)| ProfileField { name, value }) + .collect(), + } +} + +pub async fn get_pending_requests( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result>, ApiError> { + let actors = list_pending_requests(&*d.federation, &uid).await?; + Ok(Json(actors.into_iter().map(to_response).collect())) +} + +pub async fn post_accept_request( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + accept_follow_request(&*d.federation, &uid, &body.actor_url).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn delete_follower( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + reject_follow_request(&*d.federation, &uid, &body.actor_url).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_remote_followers( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result>, ApiError> { + let actors = list_remote_followers(&*d.federation, &uid).await?; + Ok(Json(actors.into_iter().map(to_response).collect())) +} + +pub async fn get_remote_following( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result>, ApiError> { + let actors = list_remote_following(&*d.federation, &uid).await?; + Ok(Json(actors.into_iter().map(to_response).collect())) +} + +pub async fn delete_following( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + remove_remote_following( + &*d.follows, + &*d.users, + &*d.federation, + &*d.events, + &uid, + &body.handle, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs new file mode 100644 index 0000000..80d95f9 --- /dev/null +++ b/crates/presentation/src/handlers/feed.rs @@ -0,0 +1,279 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps, OptionalAuthUser}, + handlers::auth::to_user_response, +}; +use api_types::requests::{PaginationQuery, SearchQuery}; +use api_types::responses::ThoughtResponse; +use application::use_cases::feed::get_home_feed; +use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username}; +use axum::{ + extract::{Path, Query}, + http::{header, HeaderMap}, + response::{IntoResponse, Response}, + Json, +}; +use domain::{ + models::feed::PageParams, + ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository}, +}; + +deps_struct!(FeedDeps { + feed: FeedRepository, + follows: FollowRepository, + search: SearchPort, + federation: FederationActionPort, + users: UserRepository, + tags: TagRepository, +}); + +pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { + ThoughtResponse { + id: e.thought.id.as_uuid(), + content: e.thought.content.as_str().to_string(), + author: to_user_response(&e.author), + in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()), + in_reply_to_url: None, + visibility: e.thought.visibility.as_str().to_string(), + content_warning: e.thought.content_warning.clone(), + sensitive: e.thought.sensitive, + like_count: e.stats.like_count, + boost_count: e.stats.boost_count, + reply_count: e.stats.reply_count, + liked_by_viewer: e.viewer.as_ref().map(|v| v.liked).unwrap_or(false), + boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false), + created_at: e.thought.created_at, + updated_at: e.thought.updated_at, + } +} + +#[utoipa::path( + get, path = "/feed", + params(PaginationQuery), + responses((status = 200, description = "Home feed")), + security(("bearer_auth" = [])) +)] +pub async fn home_feed( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?; + Ok(Json(serde_json::json!({ + "items": result.items.iter().map(to_thought_response).collect::>(), + "total": result.total, + "page": result.page, + "per_page": result.per_page, + }))) +} + +#[utoipa::path( + get, path = "/feed/public", + params(PaginationQuery), + responses((status = 200, description = "Public feed")) +)] +pub async fn public_feed( + Deps(d): Deps, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.feed.query(&FeedQuery::public(page, viewer)).await?; + Ok(Json(serde_json::json!({ + "items": result.items.iter().map(to_thought_response).collect::>(), + "total": result.total, + "page": result.page, + "per_page": result.per_page, + }))) +} + +#[utoipa::path( + get, path = "/search", + params(SearchQuery), + responses((status = 200, description = "Search results: thoughts and users")) +)] +pub async fn search_handler( + Deps(d): Deps, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page.unwrap_or(api_types::requests::DEFAULT_PAGE), + per_page: q.per_page.unwrap_or(api_types::requests::DEFAULT_PER_PAGE), + }; + let query = q.q.trim().to_string(); + + let (thoughts_result, users_result) = tokio::join!( + d.search.search_thoughts(&query, &page, viewer.as_ref()), + d.search.search_users(&query, &page), + ); + + let thoughts = thoughts_result? + .items + .iter() + .map(to_thought_response) + .collect::>(); + + let users = users_result? + .items + .into_iter() + .map(|u| to_user_response(&u)) + .collect::>(); + + Ok(Json(serde_json::json!({ + "query": query, + "thoughts": thoughts, + "users": users, + }))) +} + +pub async fn get_following_handler( + Deps(d): Deps, + Path(param): Path, + Query(q): Query, + headers: HeaderMap, +) -> Result { + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let user = get_user_by_id_or_username(&*d.users, ¶m).await?; + let user_id = user.id; + let page = q.page().try_into().ok(); + let json = d + .federation + .following_collection_json(&user_id, page) + .await?; + return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()); + } + + let user = get_user_by_username(&*d.users, ¶m).await?; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.follows.list_following(&user.id, &page).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "items": result.items.iter().map(to_user_response).collect::>() + })) + .into_response()) +} + +pub async fn get_followers_handler( + Deps(d): Deps, + Path(param): Path, + Query(q): Query, + headers: HeaderMap, +) -> Result { + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let user = get_user_by_id_or_username(&*d.users, ¶m).await?; + let user_id = user.id; + let page = q.page().try_into().ok(); + let json = d + .federation + .followers_collection_json(&user_id, page) + .await?; + return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()); + } + + let user = get_user_by_username(&*d.users, ¶m).await?; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.follows.list_followers(&user.id, &page).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "items": result.items.iter().map(to_user_response).collect::>() + })) + .into_response()) +} + +#[utoipa::path( + get, path = "/users/{username}/thoughts", + params( + ("username" = String, Path, description = "Username"), + PaginationQuery, + ), + responses((status = 200, description = "User's public thoughts")) +)] +pub async fn user_thoughts_handler( + Deps(d): Deps, + Path(username): Path, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let user = get_user_by_username(&*d.users, &username).await?; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>() + }))) +} + +pub async fn get_popular_tags( + Deps(d): Deps, + Query(params): Query>, +) -> Result, ApiError> { + let limit: usize = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize); + let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?; + Ok(Json(serde_json::json!({ + "tags": tags.iter().map(|(name, count)| serde_json::json!({ + "name": name, + "thought_count": count, + })).collect::>() + }))) +} + +#[utoipa::path( + get, path = "/tags/{name}", + params( + ("name" = String, Path, description = "Tag name"), + PaginationQuery, + ), + responses((status = 200, description = "Thoughts with this tag")) +)] +pub async fn tag_thoughts_handler( + Deps(d): Deps, + Path(tag_name): Path, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?; + Ok(Json(serde_json::json!({ + "tag": tag_name, + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} diff --git a/crates/presentation/src/handlers/health.rs b/crates/presentation/src/handlers/health.rs new file mode 100644 index 0000000..7f2904d --- /dev/null +++ b/crates/presentation/src/handlers/health.rs @@ -0,0 +1,28 @@ +use crate::{ + extractors::{Deps, FromAppState}, + state::AppState, +}; +use axum::Json; +use domain::ports::UserRepository; +use std::sync::Arc; + +pub struct HealthDeps { + pub users: Arc, +} + +impl FromAppState for HealthDeps { + fn from_state(s: &AppState) -> Self { + Self { + users: s.users.clone(), + } + } +} + +#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))] +pub async fn health_handler(Deps(d): Deps) -> Json { + let db_ok = d.users.list_with_stats().await.is_ok(); + Json(serde_json::json!({ + "status": if db_ok { "ok" } else { "degraded" }, + "db": if db_ok { "connected" } else { "error" }, + })) +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs new file mode 100644 index 0000000..44351c1 --- /dev/null +++ b/crates/presentation/src/handlers/mod.rs @@ -0,0 +1,10 @@ +pub mod api_keys; +pub mod auth; +pub mod federation_actors; +pub mod federation_management; +pub mod feed; +pub mod health; +pub mod notifications; +pub mod social; +pub mod thoughts; +pub mod users; diff --git a/crates/presentation/src/handlers/notifications.rs b/crates/presentation/src/handlers/notifications.rs new file mode 100644 index 0000000..833cecf --- /dev/null +++ b/crates/presentation/src/handlers/notifications.rs @@ -0,0 +1,119 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps}, +}; +use api_types::requests::NotificationUpdateRequest; +use application::use_cases::notifications::{ + count_unread_notifications, list_notifications as uc_list_notifications, + mark_all_notifications_read, mark_notification_read as uc_mark_notification_read, +}; +use axum::{ + extract::Path, + http::StatusCode, + Json, +}; +use domain::{ + models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId, +}; +use uuid::Uuid; + +deps_struct!(NotificationsDeps { + notifications: NotificationRepository, +}); + +#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))] +pub async fn list_notifications( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result, ApiError> { + let page = PageParams { + page: 1, + per_page: 20, + }; + let result = uc_list_notifications(&*d.notifications, &uid, page).await?; + let unread = count_unread_notifications(&*d.notifications, &uid).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "unread": unread + }))) +} + +#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))] +pub async fn mark_notification_read( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, + Json(body): Json, +) -> Result { + uc_mark_notification_read( + &*d.notifications, + &NotificationId::from_uuid(id), + &uid, + body.read, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))] +pub async fn mark_all_read( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + mark_all_notifications_read(&*d.notifications, &uid, body.read).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::make_state; + use axum::{ + body::Body, + http::{header, Request}, + routing::{get, patch}, + Router, + }; + use tower::ServiceExt; + + fn app() -> Router { + Router::new() + .route("/notifications", patch(mark_all_read)) + .route("/notifications/{id}", patch(mark_notification_read)) + .with_state(make_state()) + } + + #[tokio::test] + async fn patch_notification_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications/00000000-0000-0000-0000-000000000001") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } + + #[tokio::test] + async fn patch_all_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } +} diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs new file mode 100644 index 0000000..9cb9c4b --- /dev/null +++ b/crates/presentation/src/handlers/social.rs @@ -0,0 +1,207 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps}, +}; +use api_types::requests::SetTopFriendsRequest; +use api_types::responses::TopFriendsResponse; +use crate::handlers::auth::to_user_response; +use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; +use application::use_cases::social::*; +use axum::{ + extract::Path, + http::StatusCode, + Json, +}; +use domain::{ + ports::{ + BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, + LikeRepository, TopFriendRepository, UserRepository, + }, + value_objects::{ThoughtId, UserId}, +}; +use uuid::Uuid; + +deps_struct!(SocialDeps { + likes: LikeRepository, + boosts: BoostRepository, + follows: FollowRepository, + users: UserRepository, + federation: FederationActionPort, + events: EventPublisher, + blocks: BlockRepository, + top_friends: TopFriendRepository, +}); + +#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))] +pub async fn post_like( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + like_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))] +pub async fn delete_like( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + unlike_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))] +pub async fn post_boost( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + boost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))] +pub async fn delete_boost( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + unboost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path( + post, path = "/users/{username}/follow", + params(("username" = String, Path, description = "Username or user@domain handle")), + responses((status = 204, description = "Following")), + security(("bearer_auth" = [])) +)] +pub async fn post_follow( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + follow_actor( + &*d.follows, + &*d.users, + &*d.federation, + &*d.events, + &uid, + &username, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path( + delete, path = "/users/{username}/follow", + params(("username" = String, Path, description = "Username")), + responses((status = 204, description = "Unfollowed")), + security(("bearer_auth" = [])) +)] +pub async fn delete_follow( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + unfollow_actor( + &*d.follows, + &*d.users, + &*d.federation, + &*d.events, + &uid, + &username, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] +pub async fn post_block( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + block_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] +pub async fn delete_block( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + unblock_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))] +pub async fn put_top_friends( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + let ids: Vec = body.friend_ids.into_iter().map(UserId::from_uuid).collect(); + set_top_friends(&*d.top_friends, &uid, ids).await?; + Ok(StatusCode::NO_CONTENT) +} +#[utoipa::path(get, path = "/users/{username}/top-friends", + params(("username" = String, Path, description = "Username")), + responses((status = 200, description = "Top friends list", body = TopFriendsResponse)))] +pub async fn get_top_friends_handler( + Deps(d): Deps, + Path(username): Path, +) -> Result, ApiError> { + let user = get_user_by_username(&*d.users, &username).await?; + let friends = get_top_friends(&*d.top_friends, &user.id).await?; + let top_friends = friends.iter().map(|(_, u)| to_user_response(u)).collect(); + Ok(Json(TopFriendsResponse { top_friends })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::make_state; + use axum::{ + body::Body, + http::Request, + routing::{delete, post}, + Router, + }; + use tower::ServiceExt; + + fn app() -> Router { + Router::new() + .route( + "/users/{username}/follow", + post(post_follow).delete(delete_follow), + ) + .with_state(make_state()) + } + + #[tokio::test] + async fn follow_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/alice/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } + + #[tokio::test] + async fn unfollow_remote_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/alice@example.com/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } +} diff --git a/crates/presentation/src/handlers/thoughts.rs b/crates/presentation/src/handlers/thoughts.rs new file mode 100644 index 0000000..1e804e8 --- /dev/null +++ b/crates/presentation/src/handlers/thoughts.rs @@ -0,0 +1,179 @@ +use crate::{ + deps_struct, + errors::ApiError, + extractors::{AuthUser, Deps, OptionalAuthUser}, + handlers::feed::to_thought_response, +}; +use api_types::{ + requests::{CreateThoughtRequest, EditThoughtRequest}, + responses::ErrorResponse, +}; +use application::use_cases::thoughts::{ + create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view, + CreateThoughtInput, +}; +use axum::{ + extract::Path, + http::StatusCode, + response::IntoResponse, + Json, +}; +use domain::{ + models::feed::{EngagementStats, FeedEntry, ViewerContext}, + ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository}, + value_objects::ThoughtId, +}; +use uuid::Uuid; + +deps_struct!(ThoughtsDeps { + thoughts: ThoughtRepository, + users: UserRepository, + tags: TagRepository, + events: EventPublisher, + outbox: OutboxWriter, + engagement: EngagementRepository, +}); + +#[utoipa::path( + post, path = "/thoughts", + request_body = CreateThoughtRequest, + responses( + (status = 201, description = "Thought created"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 422, description = "Content too long", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_thought( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid); + let out = create_thought( + &*d.thoughts, + &*d.users, + &*d.tags, + &*d.events, + &*d.outbox, + CreateThoughtInput { + user_id: uid.clone(), + content: body.content, + in_reply_to_id: in_reply_to, + visibility: body.visibility, + content_warning: body.content_warning, + sensitive: body.sensitive.unwrap_or(false), + }, + ) + .await?; + let author = d + .users + .find_by_id(&uid) + .await? + .ok_or(domain::errors::DomainError::NotFound)?; + let entry = FeedEntry { + thought: out.thought, + author, + stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, + viewer: Some(ViewerContext { liked: false, boosted: false }), + }; + Ok((StatusCode::CREATED, Json(to_thought_response(&entry)))) +} + +#[utoipa::path( + get, path = "/thoughts/{id}", + params(("id" = uuid::Uuid, Path, description = "Thought ID")), + responses( + (status = 200, description = "Thought with author info"), + (status = 404, description = "Not found", body = ErrorResponse), + ) +)] +pub async fn get_thought_handler( + Deps(d): Deps, + Path(id): Path, + OptionalAuthUser(viewer): OptionalAuthUser, +) -> Result, ApiError> { + let entry = get_thought_view( + &*d.thoughts, + &*d.users, + &*d.engagement, + &ThoughtId::from_uuid(id), + viewer.as_ref(), + ) + .await?; + Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap())) +} + +#[utoipa::path( + delete, path = "/thoughts/{id}", + params(("id" = uuid::Uuid, Path, description = "Thought ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found or not owner", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_thought_handler( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { + delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, path = "/thoughts/{id}", + params(("id" = uuid::Uuid, Path, description = "Thought ID")), + request_body = EditThoughtRequest, + responses( + (status = 204, description = "Updated"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found or not owner", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_thought( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Path(id): Path, + Json(body): Json, +) -> Result { + edit_thought( + &*d.thoughts, + &*d.events, + &ThoughtId::from_uuid(id), + &uid, + body.content, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/thoughts/{id}/thread", + params(("id" = uuid::Uuid, Path, description = "Root thought ID")), + responses( + (status = 200, description = "Thread (root + replies)"), + ) +)] +pub async fn get_thread_handler( + Deps(d): Deps, + Path(id): Path, + OptionalAuthUser(viewer): OptionalAuthUser, +) -> Result>, ApiError> { + let entries = get_thread_views( + &*d.thoughts, + &*d.users, + &*d.engagement, + &ThoughtId::from_uuid(id), + viewer.as_ref(), + ) + .await?; + let items: Vec<_> = entries + .iter() + .map(|e| serde_json::to_value(to_thought_response(e)).unwrap()) + .collect(); + Ok(Json(items)) +} diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs new file mode 100644 index 0000000..0360b4e --- /dev/null +++ b/crates/presentation/src/handlers/users.rs @@ -0,0 +1,291 @@ +use crate::{ + errors::ApiError, + extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser}, + handlers::auth::to_user_response, + state::AppState, +}; +use api_types::{ + requests::{PaginationQuery, UpdateProfileRequest}, + responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse}, +}; +use application::use_cases::profile::{ + get_user as fetch_user, get_user_by_id_or_username, update_profile, +}; +use axum::{ + extract::{Path, Query}, + http::{header, HeaderMap}, + response::{IntoResponse, Response}, + Json, +}; +use domain::ports::{ + EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository, +}; +use std::sync::Arc; + +pub struct UsersDeps { + pub users: Arc, + pub events: Arc, + pub follows: Arc, + pub federation: Arc, + pub search: Arc, +} + +impl FromAppState for UsersDeps { + fn from_state(s: &AppState) -> Self { + Self { + users: s.users.clone(), + events: s.events.clone(), + follows: s.follows.clone(), + federation: s.federation.clone(), + search: s.search.clone(), + } + } +} + +#[utoipa::path( + get, path = "/users/{username}", + params(("username" = String, Path, description = "Username")), + responses( + (status = 200, body = UserResponse), + (status = 404, description = "User not found", body = ErrorResponse), + ) +)] +pub async fn get_user( + Deps(d): Deps, + Path(username): Path, + OptionalAuthUser(viewer): OptionalAuthUser, + headers: HeaderMap, +) -> Result { + let user = get_user_by_id_or_username(&*d.users, &username).await?; + + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let json = d.federation.actor_json(&user.id).await?; + Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()) + } else { + let is_followed = if let Some(viewer_id) = viewer { + d.follows.find(&viewer_id, &user.id).await?.is_some() + } else { + false + }; + let mut resp = to_user_response(&user); + resp.is_followed_by_viewer = is_followed; + Ok(Json(resp).into_response()) + } +} + +#[utoipa::path( + patch, path = "/users/me", + request_body = UpdateProfileRequest, + responses( + (status = 200, body = UserResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_profile( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result, ApiError> { + update_profile( + &*d.users, + &*d.events, + &uid, + body.display_name, + body.bio, + body.avatar_url, + body.header_url, + body.custom_css, + ) + .await?; + let user = fetch_user(&*d.users, &uid).await?; + Ok(Json(to_user_response(&user))) +} + +#[utoipa::path( + get, path = "/users/me", + responses( + (status = 200, body = UserResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_me( + Deps(d): Deps, + AuthUser(uid): AuthUser, +) -> Result, ApiError> { + let user = fetch_user(&*d.users, &uid).await?; + Ok(Json(to_user_response(&user))) +} + +pub async fn get_me_following( + Deps(d): Deps, + AuthUser(uid): AuthUser, + Query(q): Query, +) -> Result, ApiError> { + use domain::models::feed::PageParams; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = d.follows.list_following(&uid, &page).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "items": result.items.iter().map(to_user_response).collect::>(), + }))) +} + +pub async fn get_users( + Deps(d): Deps, + Query(params): Query>, +) -> Result, ApiError> { + use domain::models::feed::PageParams; + let page = params + .get("page") + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let per_page = params + .get("per_page") + .and_then(|v| v.parse::().ok()) + .unwrap_or(20); + let page_params = PageParams { page, per_page }; + + if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) { + let result = d.search.search_users(q, &page_params).await?; + let users: Vec<_> = result + .items + .iter() + .map(crate::handlers::auth::to_user_response) + .collect(); + return Ok(Json(serde_json::json!({ + "items": users, "total": result.total, "page": result.page, "per_page": result.per_page + }))); + } + + let result = d.users.list_paginated(page_params).await?; + let items: Vec<_> = result + .items + .iter() + .map(|u| { + serde_json::json!({ + "id": u.id.as_uuid(), + "username": u.username, + "displayName": u.display_name, + "avatarUrl": u.avatar_url, + "bio": u.bio, + "headerUrl": null, + "customCss": null, + "local": true, + "isFollowedByViewer": false, + "joinedAt": null, + }) + }) + .collect(); + Ok(Json(serde_json::json!({ + "items": items, "total": result.total, "page": result.page, "per_page": result.per_page + }))) +} + +pub async fn get_user_count( + Deps(d): Deps, +) -> Result, ApiError> { + let count = d.users.count().await?; + Ok(Json(serde_json::json!({ "count": count }))) +} + +#[derive(serde::Deserialize)] +pub struct LookupQuery { + pub handle: String, +} + +pub async fn lookup_handler( + Deps(d): Deps, + Query(q): Query, +) -> Result, ApiError> { + let actor = d.federation.lookup_actor(&q.handle).await?; + Ok(Json(RemoteActorResponse { + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + url: actor.url, + bio: actor.bio, + banner_url: actor.banner_url, + also_known_as: actor.also_known_as, + outbox_url: actor.outbox_url, + followers_url: actor.followers_url, + following_url: actor.following_url, + attachment: actor + .attachment + .into_iter() + .map(|(name, value)| ProfileField { name, value }) + .collect(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::make_state; + use axum::{ + body::Body, + http::{header, Request}, + routing::get, + Router, + }; + use tower::ServiceExt; + + fn app() -> Router { + Router::new() + .route("/users/{username}", get(get_user)) + .route("/users/lookup", get(lookup_handler)) + .with_state(make_state()) + } + + #[tokio::test] + async fn get_unknown_user_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } + + #[tokio::test] + async fn get_user_with_ap_accept_returns_404_when_actor_not_found() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } + + #[tokio::test] + async fn lookup_unknown_handle_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/lookup?handle=%40alice%40example.com") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..767a350 --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,10 @@ +pub mod errors; +pub mod extractors; +pub mod handlers; +pub mod openapi; +pub mod routes; +pub mod state; +#[cfg(test)] +pub mod testing; + +pub use extractors::{Deps, FromAppState}; diff --git a/crates/presentation/src/openapi/api_keys.rs b/crates/presentation/src/openapi/api_keys.rs new file mode 100644 index 0000000..2b28a5f --- /dev/null +++ b/crates/presentation/src/openapi/api_keys.rs @@ -0,0 +1,16 @@ +use api_types::{ + requests::CreateApiKeyRequest, + responses::{ApiKeyResponse, CreatedApiKeyResponse}, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::api_keys::get_api_keys, + crate::handlers::api_keys::post_api_key, + crate::handlers::api_keys::delete_api_key_handler, + ), + components(schemas(CreateApiKeyRequest, ApiKeyResponse, CreatedApiKeyResponse)) +)] +pub struct ApiKeysDoc; diff --git a/crates/presentation/src/openapi/auth.rs b/crates/presentation/src/openapi/auth.rs new file mode 100644 index 0000000..dbe252a --- /dev/null +++ b/crates/presentation/src/openapi/auth.rs @@ -0,0 +1,15 @@ +use api_types::{ + requests::{LoginRequest, RegisterRequest}, + responses::{AuthResponse, ErrorResponse}, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::auth::post_register, + crate::handlers::auth::post_login + ), + components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse)) +)] +pub struct AuthDoc; diff --git a/crates/presentation/src/openapi/feed.rs b/crates/presentation/src/openapi/feed.rs new file mode 100644 index 0000000..c8bf35c --- /dev/null +++ b/crates/presentation/src/openapi/feed.rs @@ -0,0 +1,11 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths( + crate::handlers::feed::home_feed, + crate::handlers::feed::public_feed, + crate::handlers::feed::search_handler, + crate::handlers::feed::user_thoughts_handler, + crate::handlers::feed::tag_thoughts_handler, +))] +pub struct FeedDoc; diff --git a/crates/presentation/src/openapi/health.rs b/crates/presentation/src/openapi/health.rs new file mode 100644 index 0000000..cd5eb5b --- /dev/null +++ b/crates/presentation/src/openapi/health.rs @@ -0,0 +1,5 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths(crate::handlers::health::health_handler))] +pub struct HealthDoc; diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs new file mode 100644 index 0000000..a3203e1 --- /dev/null +++ b/crates/presentation/src/openapi/mod.rs @@ -0,0 +1,61 @@ +mod api_keys; +mod auth; +mod feed; +mod health; +mod notifications; +mod social; +mod thoughts; +mod users; + +use axum::Router; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_scalar::{Scalar, Servable}; +use utoipa_swagger_ui::SwaggerUi; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Api-Key"))), + ); + } +} + +fn build() -> utoipa::openapi::OpenApi { + let mut api = auth::AuthDoc::openapi(); + api.info = utoipa::openapi::InfoBuilder::new() + .title("Thoughts API") + .version("2.0.0") + .description(Some( + "Federated social network API. Authenticate via `POST /auth/login` to get a Bearer token, \ + or use `X-Api-Key` header with a key from `POST /api-keys`." + )) + .build(); + api.merge(users::UsersDoc::openapi()); + api.merge(thoughts::ThoughtsDoc::openapi()); + api.merge(feed::FeedDoc::openapi()); + api.merge(social::SocialDoc::openapi()); + api.merge(notifications::NotificationsDoc::openapi()); + api.merge(api_keys::ApiKeysDoc::openapi()); + api.merge(health::HealthDoc::openapi()); + SecurityAddon.modify(&mut api); + api +} + +pub fn serve(router: Router) -> Router { + tracing::info!("API docs at /docs (Swagger UI) and /scalar (Scalar)"); + let spec = build(); + router + .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) + .merge(Scalar::with_url("/scalar", spec)) +} diff --git a/crates/presentation/src/openapi/notifications.rs b/crates/presentation/src/openapi/notifications.rs new file mode 100644 index 0000000..dfd757f --- /dev/null +++ b/crates/presentation/src/openapi/notifications.rs @@ -0,0 +1,9 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths( + crate::handlers::notifications::list_notifications, + crate::handlers::notifications::mark_notification_read, + crate::handlers::notifications::mark_all_read, +))] +pub struct NotificationsDoc; diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs new file mode 100644 index 0000000..ab90680 --- /dev/null +++ b/crates/presentation/src/openapi/social.rs @@ -0,0 +1,20 @@ +use api_types::requests::SetTopFriendsRequest; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::social::post_like, + crate::handlers::social::delete_like, + crate::handlers::social::post_boost, + crate::handlers::social::delete_boost, + crate::handlers::social::post_follow, + crate::handlers::social::delete_follow, + crate::handlers::social::post_block, + crate::handlers::social::delete_block, + crate::handlers::social::put_top_friends, + crate::handlers::social::get_top_friends_handler, + ), + components(schemas(SetTopFriendsRequest)) +)] +pub struct SocialDoc; diff --git a/crates/presentation/src/openapi/thoughts.rs b/crates/presentation/src/openapi/thoughts.rs new file mode 100644 index 0000000..3796464 --- /dev/null +++ b/crates/presentation/src/openapi/thoughts.rs @@ -0,0 +1,18 @@ +use api_types::{ + requests::{CreateThoughtRequest, EditThoughtRequest}, + responses::ErrorResponse, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::thoughts::post_thought, + crate::handlers::thoughts::get_thought_handler, + crate::handlers::thoughts::patch_thought, + crate::handlers::thoughts::delete_thought_handler, + crate::handlers::thoughts::get_thread_handler, + ), + components(schemas(CreateThoughtRequest, EditThoughtRequest, ErrorResponse)) +)] +pub struct ThoughtsDoc; diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs new file mode 100644 index 0000000..df6fd62 --- /dev/null +++ b/crates/presentation/src/openapi/users.rs @@ -0,0 +1,16 @@ +use api_types::{ + requests::UpdateProfileRequest, + responses::{ErrorResponse, UserResponse}, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::users::get_me, + crate::handlers::users::get_user, + crate::handlers::users::patch_profile, + ), + components(schemas(UserResponse, UpdateProfileRequest, ErrorResponse)) +)] +pub struct UsersDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs new file mode 100644 index 0000000..d0025cc --- /dev/null +++ b/crates/presentation/src/routes.rs @@ -0,0 +1,117 @@ +use crate::{handlers::*, openapi, state::AppState}; +use axum::{ + routing::{delete, get, patch, post, put}, + Router, +}; + +pub fn router() -> Router { + let api_routes = Router::new() + // health + .route("/health", get(health::health_handler)) + // auth + .route("/auth/register", post(auth::post_register)) + .route("/auth/login", post(auth::post_login)) + // users — static before parameterised + .route("/users", get(users::get_users)) + .route("/users/count", get(users::get_user_count)) + .route("/users/lookup", get(users::lookup_handler)) + .route("/users/me", get(users::get_me).patch(users::patch_profile)) + .route("/users/me/following", get(users::get_me_following)) + .route("/users/me/top-friends", put(social::put_top_friends)) + .route("/users/{username}", get(users::get_user)) + .route( + "/users/{username}/top-friends", + get(social::get_top_friends_handler), + ) + .route( + "/users/{username}/follow", + post(social::post_follow).delete(social::delete_follow), + ) + .route( + "/users/{username}/block", + post(social::post_block).delete(social::delete_block), + ) + .route( + "/users/{username}/followers", + get(feed::get_followers_handler), + ) + .route( + "/users/{username}/following", + get(feed::get_following_handler), + ) + .route( + "/users/{username}/thoughts", + get(feed::user_thoughts_handler), + ) + // thoughts + .route("/thoughts", post(thoughts::post_thought)) + .route( + "/thoughts/{id}", + get(thoughts::get_thought_handler) + .patch(thoughts::patch_thought) + .delete(thoughts::delete_thought_handler), + ) + .route("/thoughts/{id}/thread", get(thoughts::get_thread_handler)) + // likes & boosts + .route( + "/thoughts/{id}/like", + post(social::post_like).delete(social::delete_like), + ) + .route( + "/thoughts/{id}/boost", + post(social::post_boost).delete(social::delete_boost), + ) + // feeds + .route("/feed", get(feed::home_feed)) + .route("/feed/public", get(feed::public_feed)) + .route("/search", get(feed::search_handler)) + .route( + "/federation/actors/{handle}/posts", + get(federation_actors::remote_actor_posts_handler), + ) + .route( + "/federation/actors/{handle}/followers-list", + get(federation_actors::actor_followers_handler), + ) + .route( + "/federation/actors/{handle}/following-list", + get(federation_actors::actor_following_handler), + ) + .route( + "/federation/me/followers/pending", + get(federation_management::get_pending_requests), + ) + .route( + "/federation/me/followers/accept", + post(federation_management::post_accept_request), + ) + .route( + "/federation/me/followers", + get(federation_management::get_remote_followers) + .delete(federation_management::delete_follower), + ) + .route( + "/federation/me/following", + get(federation_management::get_remote_following) + .delete(federation_management::delete_following), + ) + .route("/tags/popular", get(feed::get_popular_tags)) + .route("/tags/{name}", get(feed::tag_thoughts_handler)) + // notifications + .route( + "/notifications", + get(notifications::list_notifications).patch(notifications::mark_all_read), + ) + .route( + "/notifications/{id}", + patch(notifications::mark_notification_read), + ) + // api keys + .route( + "/api-keys", + get(api_keys::get_api_keys).post(api_keys::post_api_key), + ) + .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)); + + openapi::serve(api_routes) +} diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs new file mode 100644 index 0000000..8fc0b20 --- /dev/null +++ b/crates/presentation/src/state.rs @@ -0,0 +1,30 @@ +use activitypub_base::ActivityPubRepository; +use domain::ports::*; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub users: Arc, + pub thoughts: Arc, + pub likes: Arc, + pub boosts: Arc, + pub follows: Arc, + pub blocks: Arc, + pub tags: Arc, + pub api_keys: Arc, + pub api_key_auth: Arc, + pub top_friends: Arc, + pub notifications: Arc, + pub remote_actors: Arc, + pub feed: Arc, + pub search: Arc, + pub auth: Arc, + pub hasher: Arc, + pub events: Arc, + pub outbox: Arc, + pub federation: Arc, + pub ap_repo: Arc, + pub remote_actor_connections: Arc, + pub federation_scheduler: Arc, + pub engagement: Arc, +} diff --git a/crates/presentation/src/testing.rs b/crates/presentation/src/testing.rs new file mode 100644 index 0000000..16f7506 --- /dev/null +++ b/crates/presentation/src/testing.rs @@ -0,0 +1,139 @@ +use crate::state::AppState; +use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + testing::{NoOpOutboxWriter, TestStore}, + value_objects::{PasswordHash, ThoughtId, UserId}, +}; +use std::sync::Arc; + +pub struct NoOpAuth; +impl AuthService for NoOpAuth { + fn generate_token(&self, _uid: &UserId) -> Result { + Err(DomainError::Internal("noop".into())) + } + fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::Unauthorized) + } +} + +pub struct NoOpHasher; +#[async_trait] +impl PasswordHasher for NoOpHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Ok(false) + } +} + +/// No-op ActivityPubRepository for presentation layer tests. +pub struct NoOpApRepo; + +#[async_trait] +impl ActivityPubRepository for NoOpApRepo { + async fn outbox_entries_for_actor( + &self, + _uid: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn outbox_page_for_actor( + &self, + _uid: &UserId, + _before: Option>, + _limit: usize, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn find_remote_actor_id( + &self, + _actor_ap_url: &str, + ) -> Result, DomainError> { + Ok(None) + } + async fn intern_remote_actor(&self, _actor_ap_url: &str) -> Result { + Err(DomainError::NotFound) + } + async fn update_remote_actor_display( + &self, + _user_id: &UserId, + _display_name: Option<&str>, + _avatar_url: Option<&str>, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn accept_note( + &self, + _ap_id: &str, + _author_id: &UserId, + _content: &str, + _published: chrono::DateTime, + _sensitive: bool, + _content_warning: Option, + _visibility: &str, + _in_reply_to: Option<&str>, + ) -> Result { + Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) + } + async fn apply_note_update( + &self, + _ap_id: &str, + _new_content: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn count_local_notes(&self) -> Result { + Ok(0) + } + async fn get_thought_ap_id( + &self, + _thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(None) + } + async fn get_actor_ap_urls( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + Ok(None) + } +} + +pub fn make_state() -> AppState { + let store = Arc::new(TestStore::default()); + AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: Arc::new(NoOpAuth), + hasher: Arc::new(NoOpHasher), + events: store.clone(), + outbox: Arc::new(NoOpOutboxWriter), + federation: store.clone(), + ap_repo: Arc::new(NoOpApRepo), + remote_actor_connections: store.clone(), + federation_scheduler: store.clone(), + api_key_auth: store.clone(), + engagement: store.clone(), + } +} diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml new file mode 100644 index 0000000..54fa65a --- /dev/null +++ b/crates/worker/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "worker" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "thoughts-worker" +path = "src/main.rs" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +nats = { workspace = true } +event-transport = { workspace = true } +event-payload = { workspace = true } +activitypub-base = { workspace = true } +activitypub = { workspace = true } +postgres = { workspace = true } +postgres-federation = { workspace = true } +async-nats = { workspace = true } +tokio = { workspace = true, features = ["full"] } +futures = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/worker/src/dlq.rs b/crates/worker/src/dlq.rs new file mode 100644 index 0000000..4f66fb8 --- /dev/null +++ b/crates/worker/src/dlq.rs @@ -0,0 +1,64 @@ +use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; +use postgres::failed_event::{PgFailedEventStore, DLQ_MAX_RETRIES, DLQ_POLL_INTERVAL_SECS}; +use std::sync::Arc; + +/// Background task: polls `failed_events` and republishes due rows to the event bus. +pub async fn run_dlq_processor(store: Arc, publisher: Arc) { + let interval = std::time::Duration::from_secs(DLQ_POLL_INTERVAL_SECS); + loop { + tokio::time::sleep(interval).await; + if let Err(e) = process_due(&store, &*publisher).await { + tracing::error!("DLQ processor error: {e}"); + } + } +} + +async fn process_due( + store: &PgFailedEventStore, + publisher: &dyn EventPublisher, +) -> Result<(), sqlx::Error> { + let due = store.poll_due().await?; + if due.is_empty() { + return Ok(()); + } + tracing::info!(count = due.len(), "DLQ: processing due events"); + + for row in due { + if row.retry_count >= DLQ_MAX_RETRIES { + tracing::error!( + id = %row.id, + event_type = %row.event_type, + retry_count = row.retry_count, + "DLQ: event permanently failed — parking", + ); + store.park_permanently(row.id).await?; + continue; + } + + let republish_result = republish(&row.payload, publisher).await; + + match republish_result { + Ok(()) => { + tracing::info!(id = %row.id, "DLQ: republished successfully"); + store.advance(row.id, None).await?; + } + Err(e) => { + tracing::warn!(id = %row.id, error = %e, "DLQ: republish failed"); + store.advance(row.id, Some(&e.to_string())).await?; + } + } + } + Ok(()) +} + +async fn republish( + payload: &serde_json::Value, + publisher: &dyn EventPublisher, +) -> Result<(), DomainError> { + use event_payload::EventPayload; + let ep: EventPayload = serde_json::from_value(payload.clone()) + .map_err(|e| DomainError::Internal(format!("DLQ deserialize: {e}")))?; + let event = DomainEvent::try_from(ep) + .map_err(|e| DomainError::Internal(format!("DLQ event conversion: {e}")))?; + publisher.publish(&event).await +} diff --git a/crates/worker/src/factory.rs b/crates/worker/src/factory.rs new file mode 100644 index 0000000..bc43891 --- /dev/null +++ b/crates/worker/src/factory.rs @@ -0,0 +1,114 @@ +use postgres::failed_event::PgFailedEventStore; +use sqlx::PgPool; +use std::sync::Arc; + +use activitypub::ThoughtsObjectHandler; +use activitypub_base::ActivityPubService; +use application::services::{FederationEventService, NotificationEventService}; +use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; +use domain::ports::EventPublisher; +use postgres::activitypub::PgActivityPubRepository; +use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; + +use crate::handlers::{FederationHandler, NotificationHandler}; + +pub struct WorkerHandlers { + pub notification: NotificationHandler, + pub federation: FederationHandler, +} + +pub struct WorkerInfra { + pub pool: PgPool, + pub consumer: event_transport::EventConsumerAdapter, + pub handlers: WorkerHandlers, + pub dlq_store: Arc, + pub event_publisher: Arc, +} + +pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> WorkerInfra { + let pool = PgPool::connect(database_url) + .await + .expect("DB connect failed"); + + // Repos + let thoughts = Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())); + let users = Arc::new(postgres::user::PgUserRepository::new(pool.clone())); + let notifications = Arc::new(postgres::notification::PgNotificationRepository::new( + pool.clone(), + )); + + // ActivityPub service (for federation fan-out) + let ap_service = Arc::new( + ActivityPubService::new( + Arc::new(PostgresFederationRepository::new(pool.clone())), + Arc::new(PostgresApUserRepository::new( + pool.clone(), + base_url.to_string(), + )), + Arc::new(ThoughtsObjectHandler::new( + Arc::new(PgActivityPubRepository::new(pool.clone())), + base_url, + None, + Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), + )), + base_url.to_string(), + false, + "thoughts".to_string(), + false, + None, + ) + .await + .expect("ActivityPubService build failed"), + ); + let ap_outbound = ap_service.clone() as Arc; + let ap_repo_worker = + Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; + + // Application services + let notification_svc = Arc::new(NotificationEventService { + thoughts: thoughts.clone(), + notifications, + }); + let federation_svc = Arc::new(FederationEventService { + thoughts, + users, + ap: ap_outbound, + base_url: base_url.to_string(), + ap_repo: ap_repo_worker, + }); + + // Thin handlers + let handlers = WorkerHandlers { + notification: NotificationHandler { + service: notification_svc, + }, + federation: FederationHandler { + service: federation_svc, + }, + }; + + // DLQ store + let dlq_store = Arc::new(PgFailedEventStore::new(pool.clone())); + + // NATS consumer + publisher + let nats_client = async_nats::connect(nats_url) + .await + .expect("NATS connect failed"); + nats::ensure_stream(&nats_client) + .await + .expect("JetStream stream setup failed"); + let consumer = event_transport::EventConsumerAdapter::new(nats::NatsMessageSource::new( + nats_client.clone(), + )); + let event_publisher: Arc = Arc::new( + event_transport::EventPublisherAdapter::new(nats::NatsTransport::new(nats_client)), + ); + + WorkerInfra { + pool, + consumer, + handlers, + dlq_store, + event_publisher, + } +} diff --git a/crates/worker/src/handlers.rs b/crates/worker/src/handlers.rs new file mode 100644 index 0000000..2adb2a4 --- /dev/null +++ b/crates/worker/src/handlers.rs @@ -0,0 +1,23 @@ +use application::services::{FederationEventService, NotificationEventService}; +use domain::{errors::DomainError, events::DomainEvent}; +use std::sync::Arc; + +pub struct NotificationHandler { + pub service: Arc, +} + +impl NotificationHandler { + pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.service.process(event).await + } +} + +pub struct FederationHandler { + pub service: Arc, +} + +impl FederationHandler { + pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.service.process(event).await + } +} diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs new file mode 100644 index 0000000..c5caa91 --- /dev/null +++ b/crates/worker/src/main.rs @@ -0,0 +1,97 @@ +mod dlq; +mod factory; +mod handlers; +mod outbox_relay; + +use domain::ports::EventConsumer; +use futures::StreamExt; +use nats::CONSUMER_MAX_DELIVER; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); + let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); + let base_url = std::env::var("BASE_URL").expect("BASE_URL required"); + + tracing::info!("Building worker..."); + let infra = factory::build(&database_url, &base_url, &nats_url).await; + + // Spawn DLQ processor as a background task. + tokio::spawn(dlq::run_dlq_processor( + infra.dlq_store.clone(), + infra.event_publisher.clone(), + )); + + // Spawn outbox relay — polls DB for undelivered events and publishes them. + tokio::spawn( + outbox_relay::OutboxRelay { + pool: infra.pool.clone(), + publisher: infra.event_publisher.clone(), + poll_interval: std::time::Duration::from_secs(5), + } + .run(), + ); + + tracing::info!("Worker started, consuming events..."); + let mut stream = infra.consumer.consume(); + while let Some(result) = stream.next().await { + match result { + Ok(envelope) => { + let event = &envelope.event; + tracing::debug!(?event, "received event"); + + let n = infra.handlers.notification.handle(event).await; + let f = infra.handlers.federation.handle(event).await; + + if n.is_ok() && f.is_ok() { + (envelope.ack)(); + } else { + if let Err(e) = &n { + tracing::error!("notification handler: {e}"); + } + if let Err(e) = &f { + tracing::error!("federation handler: {e}"); + } + + // Last delivery attempt -> move to DLQ then ack. + // Earlier attempts -> nack so NATS retries. + if envelope.delivery_count >= CONSUMER_MAX_DELIVER as u64 { + let error_msg = n + .err() + .or(f.err()) + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown error".into()); + + // Serialize event back to payload for storage. + let ep = event_payload::EventPayload::from(event); + let event_type = ep.subject().to_string(); + let payload = serde_json::to_value(&ep).unwrap_or(serde_json::Value::Null); + + if let Err(e) = infra + .dlq_store + .insert(&event_type, &payload, &error_msg) + .await + { + tracing::error!("DLQ insert failed: {e} — message lost"); + } else { + tracing::warn!( + event_type, + delivery_count = envelope.delivery_count, + "event exhausted — moved to DLQ" + ); + } + (envelope.ack)(); // ack from NATS — DLQ owns it now + } else { + (envelope.nack)(); + } + } + } + Err(e) => tracing::error!("consumer error: {e}"), + } + } +} diff --git a/crates/worker/src/outbox_relay.rs b/crates/worker/src/outbox_relay.rs new file mode 100644 index 0000000..d8fd0ff --- /dev/null +++ b/crates/worker/src/outbox_relay.rs @@ -0,0 +1,114 @@ +use domain::{events::DomainEvent, ports::EventPublisher}; +use event_payload::EventPayload; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::Duration; + +pub struct OutboxRelay { + pub pool: PgPool, + pub publisher: Arc, + pub poll_interval: Duration, +} + +#[derive(sqlx::FromRow)] +struct OutboxRow { + seq: i64, + event_type: String, + payload: serde_json::Value, +} + +impl OutboxRelay { + pub async fn run(self) { + loop { + if let Err(e) = self.process_batch().await { + tracing::error!("outbox relay error: {e}"); + } + tokio::time::sleep(self.poll_interval).await; + } + } + + // NOTE: thoughts.save() and outbox.append() are not in the same DB transaction + // (known architectural limitation — fixing requires transaction-sharing between + // repositories, a larger refactor). + async fn process_batch(&self) -> Result<(), sqlx::Error> { + // Process one row at a time inside its own transaction so that + // FOR UPDATE SKIP LOCKED actually holds the lock for the duration + // of publish + mark_delivered. A batch SELECT without a surrounding + // transaction releases locks immediately after autocommit. + loop { + let mut tx = self.pool.begin().await?; + + let row = sqlx::query_as::<_, OutboxRow>( + "SELECT seq, event_type, payload \ + FROM outbox_events \ + WHERE delivered = false \ + ORDER BY seq ASC \ + LIMIT 1 \ + FOR UPDATE SKIP LOCKED", + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(row) = row else { + tx.rollback().await?; + break; + }; + + let payload: EventPayload = match serde_json::from_value(row.payload.clone()) { + Ok(p) => p, + Err(e) => { + tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}"); + // Mark delivered to avoid blocking; investigate manually. + sqlx::query( + "UPDATE outbox_events \ + SET delivered = true, delivered_at = now() \ + WHERE seq = $1", + ) + .bind(row.seq) + .execute(&mut *tx) + .await?; + tx.commit().await?; + continue; + } + }; + + let domain_event = match DomainEvent::try_from(payload) { + Ok(ev) => ev, + Err(e) => { + tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}"); + sqlx::query( + "UPDATE outbox_events \ + SET delivered = true, delivered_at = now() \ + WHERE seq = $1", + ) + .bind(row.seq) + .execute(&mut *tx) + .await?; + tx.commit().await?; + continue; + } + }; + + match self.publisher.publish(&domain_event).await { + Ok(()) => { + sqlx::query( + "UPDATE outbox_events \ + SET delivered = true, delivered_at = now() \ + WHERE seq = $1", + ) + .bind(row.seq) + .execute(&mut *tx) + .await?; + tx.commit().await?; + tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered"); + } + Err(e) => { + tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}"); + tx.rollback().await?; // row stays undelivered, retried next poll + } + } + } + + Ok(()) + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a6e476e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +REGISTRY="registry.gabrielkaszewski.dev" +BACKEND_IMAGE="$REGISTRY/thoughts:latest" +FRONTEND_IMAGE="$REGISTRY/thoughts-frontend:latest" + +# Public API URL seen by the browser. +# Override with: NEXT_PUBLIC_API_URL=https://api.example.com ./deploy.sh +API_URL="${NEXT_PUBLIC_API_URL:-https://api.thoughts.gabrielkaszewski.dev}" + +# Internal API URL used by Next.js SSR (can be a Docker-internal address in prod). +# Override with: NEXT_PUBLIC_SERVER_SIDE_API_URL=http://api:8000 ./deploy.sh +SSR_API_URL="${NEXT_PUBLIC_SERVER_SIDE_API_URL:-$API_URL}" + +echo "==> building backend image: $BACKEND_IMAGE" +docker buildx build --platform linux/amd64 \ + -t "$BACKEND_IMAGE" --push . + +echo "==> building frontend image: $FRONTEND_IMAGE" +docker buildx build --platform linux/amd64 \ + --build-arg "NEXT_PUBLIC_API_URL=$API_URL" \ + --build-arg "NEXT_PUBLIC_SERVER_SIDE_API_URL=$SSR_API_URL" \ + -t "$FRONTEND_IMAGE" --push \ + ./thoughts-frontend + +echo "==> pushed $BACKEND_IMAGE" +echo "==> pushed $FRONTEND_IMAGE" diff --git a/docs/movies-diary-integration.md b/docs/movies-diary-integration.md new file mode 100644 index 0000000..8f32441 --- /dev/null +++ b/docs/movies-diary-integration.md @@ -0,0 +1,122 @@ +# Movies-Diary First-Class Integration + +Since thoughts and movies-diary are both owned projects, movies-diary can be treated as a first-class citizen with deep, structured integration rather than a generic ActivityPub instance. + +## Core idea + +Add a custom ActivityPub `@context` extension to movies-diary's AP notes that carries structured movie review data. Thoughts understands this extension and renders movie review posts as rich cards instead of plain text. Movies-diary actor profiles in thoughts get a dedicated "Movie Diary" layout. + +--- + +## Feature 1 — Custom AP Extension for Movie Reviews + +### movies-diary side + +Extend the AP Note with a `movies-diary` namespace in `@context`: + +```json +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "md": "https://movies.gabrielkaszewski.dev/ns#", + "movieReview": "md:movieReview", + "movieTitle": "md:movieTitle", + "movieYear": "md:movieYear", + "rating": "md:rating", + "maxRating": "md:maxRating", + "watchedAt": "md:watchedAt", + "posterUrl": "md:posterUrl", + "tmdbId": "md:tmdbId" + } + ], + "type": "Note", + "movieReview": true, + "movieTitle": "Eternals", + "movieYear": 2021, + "rating": 3, + "maxRating": 5, + "watchedAt": "2025-09-30", + "posterUrl": "https://image.tmdb.org/t/p/w300/...", + "tmdbId": 524434, + "content": "

⭐⭐⭐ Eternals (2021) Watched: Sep 30, 2025

" +} +``` + +The `content` field keeps the plain-text fallback so the post still renders correctly in any standard AP client. + +### thoughts side + +When fetching remote notes in `fetch_outbox_page`, detect the extension fields and store the structured data alongside the note. This requires: + +- A new `remote_note_meta` table (or a JSON column on `thoughts`) for: `movie_title`, `movie_year`, `rating`, `max_rating`, `watched_at`, `poster_url`, `tmdb_id` +- A new domain model field or separate `MovieReviewMeta` struct +- The thought card in the frontend checks for this metadata and renders a `MovieReviewCard` component instead of plain text + +### `MovieReviewCard` component + +Shows: +- Movie poster (from `posterUrl`) +- Title + year +- Star rating (visual, not emoji) +- Watched date +- Optional review text (the `content` stripped of the auto-generated prefix) +- Link to the movie on the user's movies-diary instance + +--- + +## Feature 2 — Dedicated Movies-Diary Actor Profile + +When viewing an actor profile from a movies-diary instance (detected by actor URL domain or a custom AP actor field), the profile page shows a "Movie Diary" layout instead of the generic remote actor profile. + +### Detection + +Add a custom field to movies-diary's AP `Person` object: + +```json +{ + "type": "Person", + "md:softwareName": "movies-diary", + "md:instanceUrl": "https://movies.gabrielkaszewski.dev" +} +``` + +Thoughts checks for `md:softwareName = "movies-diary"` and switches to the dedicated layout. + +### Movie Diary profile layout + +- **Header**: same avatar/banner/bio/follow button as the generic profile +- **Stats bar**: Total reviews · Watchlist size · Avg rating +- **Recent reviews grid**: Movie poster cards (not a feed of text posts) — each shows poster, title, year, rating, watched date +- **Tabs**: Recent Reviews | Watchlist | Following (other movie diary users) +- **Watchlist tab**: Shows movies marked as "want to watch" (requires a custom AP Collection type: `md:Watchlist`) + +### API + +The movies-diary instance exposes custom AP endpoints that thoughts can call (since it owns both): + +- `GET /ap/users/{username}/watchlist` — returns AP OrderedCollection of watchlist items (with `md:` fields) +- `GET /ap/users/{username}/reviews?page=1` — returns AP OrderedCollectionPage of reviews (rich notes) + +Thoughts fetches these when rendering the movie diary profile, similar to how it fetches the outbox. + +--- + +## Implementation order (when ready) + +1. Define and document the `md:` namespace schema in movies-diary +2. Emit `md:` fields on movies-diary AP notes and Person objects +3. Extend thoughts `fetch_outbox_page` to parse and store `md:` fields +4. Build `MovieReviewCard` frontend component +5. Add detection logic for movies-diary actors +6. Build the dedicated Movie Diary profile layout + watchlist/reviews tabs +7. Implement the custom AP endpoints on movies-diary side + +--- + +## Notes + +- The `content` fallback in AP notes ensures movies-diary posts remain readable in Mastodon, Pleroma, and any other standard client — the extension is additive +- The `md:` namespace URL should resolve to a JSON-LD context document for proper AP compliance +- Authentication between thoughts and movies-diary can use the existing AP HTTP signatures, so no separate auth system is needed +- TMDB poster URLs may require a TMDB API key on movies-diary's side; thoughts just stores and displays the URL diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index fec5616..0000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM nginx:stable-alpine - -RUN rm /etc/nginx/conf.d/default.conf - -COPY nginx.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index bf2fe80..0000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,42 +0,0 @@ -upstream frontend { - server frontend:3000; -} - -upstream backend { - server backend:8000; -} - -server { - listen 80; - server_name localhost; - - location /health { - return 200 "OK"; - access_log off; - } - - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - - location /api/ { - rewrite /api/(.*) /$1 break; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_pass http://backend; - } - - location / { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_pass http://frontend; - } -} diff --git a/thoughts-backend/README.md b/thoughts-backend/README.md index 5f3707e..2993145 100644 --- a/thoughts-backend/README.md +++ b/thoughts-backend/README.md @@ -1,129 +1,17 @@ -# clean-axum +# ⚠️ DEPRECATED — thoughts-backend (v1) -Axum scaffold with clean architecture. +> **This directory is the original v1 implementation and is no longer maintained.** +> It will be removed in a future release. -You probably don't need [Rust on Rails](https://github.com/loco-rs/loco). +## Use v2 instead -Refer to [this post](https://kigawas.me/posts/rustacean-clean-architecture-approach/) for rationale and background. +The active codebase lives at the **repository root** (`/crates/`). It is a complete rewrite with: -## Features +- Hexagonal (Ports & Adapters) architecture +- Full ActivityPub federation +- Remote actor discovery and profile browsing +- NATS JetStream event bus +- Clean REST API with content negotiation +- Next.js frontend (`/thoughts-frontend/`) -- [Axum](https://github.com/tokio-rs/axum) framework -- [SeaORM](https://github.com/SeaQL/sea-orm) domain models -- Completely separated API routers and DB-related logic (named "persistence" layer) -- Completely separated input parameters, queries and output schemas -- OpenAPI documentation ([Swagger UI](https://clean-axum.shuttleapp.rs/docs) and [Scalar](https://clean-axum.shuttleapp.rs/scalar)) powered by [Utoipa](https://github.com/juhaku/utoipa) -- Error handling with [Anyhow](https://github.com/dtolnay/anyhow) -- Custom parameter validation with [validator](https://github.com/Keats/validator) -- Optional [Shuttle](https://www.shuttle.rs/) runtime -- Optional [prefork](https://docs.rs/prefork/latest/prefork/) workers for maximizing performance on Linux - -## Module hierarchy - -### API logic - -- `api::routers`: Axum endpoints -- `api::error`: Models and traits for error handling -- `api::extractor` Custom Axum extractors - - `api::extractor::json`: `Json` for bodies and responses - - `api::extractor::valid`: `Valid` for JSON body validation -- `api::validation`: JSON validation model based on `validator` -- `api::models`: Non domain model API models - - `api::models::response`: JSON error response - -### OpenAPI documentation - -- `doc`: Utoipa doc declaration - -### API-agonistic application logic - -Main concept: Web framework is replaceable. - -All modules here should not include any specific API web framework logic. - -- `app::persistence`: DB manipulation (CRUD) functions -- `app::config`: DB or API server configuration -- `app::state`: APP state, e.g. DB connection -- `app::error`: APP errors used by `api::error`. e.g. "User not found" - -### DB/API-agnostic domain models - -Main concept: Database (Sqlite/MySQL/PostgreSQL) is replaceable. - -Except `models::domains` and `migration`, all modules are ORM library agnostic. - -- `models::domains`: SeaORM domain models -- `models::params`: Serde input parameters for creating/updating domain models in DB -- `models::schemas`: Serde output schemas for combining different domain models -- `models::queries`: Serde queries for filtering domain models -- `migration`: SeaORM migration files - -### Unit and integration tests - -- `tests::api`: API integration tests. Hierarchy is the same as `api::routers` -- `tests::app::persistence`: DB/ORM-related unit tests. Hierarchy is the same as `app::persistence` - -### Others - -- `utils`: Utility functions -- `main`: Tokio and Shuttle conditional entry point - -## Run - -### Start server - -```bash -cp .env.example .env -# touch dev.db -# cargo install sea-orm-cli -# sea-orm-cli migrate up -cargo run - -# or for production -cargo run --release -``` - -### Call API - -```bash -curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"aaa"}' -curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"abc"}' -curl http://localhost:3000/users\?username\=a -``` - -### OpenAPI doc (Swagger UI/Scalar) - -```bash -open http://localhost:3000/docs -open http://localhost:3000/scalar -``` - -## Start Shuttle local server - -```bash -# cargo install cargo-shuttle -cargo shuttle run -``` - -Make sure docker engine is running, otherwise: - -```bash -brew install colima docker -colima start -sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock -``` - -## Shuttle deployment - -```bash -cargo shuttle login -cargo shuttle deploy -``` - -## Benchmark - -```bash -# edit .env to use Postgres -cargo run --release -wrk --latency -t20 -c50 -d10s http://localhost:3000/users\?username\= -``` +Do not build, run, or modify anything in this directory. diff --git a/thoughts-frontend/Dockerfile b/thoughts-frontend/Dockerfile index 6873141..15a2879 100644 --- a/thoughts-frontend/Dockerfile +++ b/thoughts-frontend/Dockerfile @@ -4,6 +4,9 @@ WORKDIR /app ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_SERVER_SIDE_API_URL +ENV NEXT_PUBLIC_SERVER_SIDE_API_URL=$NEXT_PUBLIC_SERVER_SIDE_API_URL + # Install dependencies with Bun for speed COPY --chown=node:node package.json bun.lock ./ RUN npm install -g bun diff --git a/thoughts-frontend/README.md b/thoughts-frontend/README.md index e215bc4..7adebbe 100644 --- a/thoughts-frontend/README.md +++ b/thoughts-frontend/README.md @@ -1,36 +1,47 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Thoughts — Frontend -## Getting Started +Next.js 15 (App Router) frontend for the [Thoughts](../) self-hosted microblogging server. -First, run the development server: +## Features + +- Post thoughts, reply, boost, and like +- Home feed, public feed, per-user timelines +- Browse and follow remote Fediverse actors by `@user@instance` handle +- Full remote actor profiles — bio, banner, profile fields, posts tab, followers/following tabs +- Full-text search for local users and thoughts; remote actor lookup via WebFinger +- Notifications, API key management, profile editing +- Dark/light theme + +## Setup ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Copy `.env.local.example` to `.env.local` (or set the variables directly): -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +`NEXT_PUBLIC_API_URL` is used by client-side fetches (runs in the browser). +`NEXT_PUBLIC_SERVER_SIDE_API_URL` is used by server-side fetches (runs in Next.js SSR — can point to an internal service URL in Docker). -## Learn More +## Run -To learn more about Next.js, take a look at the following resources: +```bash +bun run dev # development — http://localhost:3000 +bun run build # production build +bun run start # serve production build +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Docker -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +docker build \ + --build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \ + --build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \ + -t thoughts-frontend . +docker run -p 3000:3000 thoughts-frontend +``` diff --git a/thoughts-frontend/app/(auth)/layout.tsx b/thoughts-frontend/app/(auth)/layout.tsx index d948319..004d951 100644 --- a/thoughts-frontend/app/(auth)/layout.tsx +++ b/thoughts-frontend/app/(auth)/layout.tsx @@ -1,4 +1,10 @@ // app/(auth)/layout.tsx +import type { Metadata } from "next"; + +export const metadata: Metadata = { + openGraph: { type: "website" }, +}; + export default function AuthLayout({ children, }: { diff --git a/thoughts-frontend/app/(auth)/login/layout.tsx b/thoughts-frontend/app/(auth)/login/layout.tsx new file mode 100644 index 0000000..07c8d60 --- /dev/null +++ b/thoughts-frontend/app/(auth)/login/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in to your Thoughts account", +}; + +export default function LoginLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/thoughts-frontend/app/(auth)/login/page.tsx b/thoughts-frontend/app/(auth)/login/page.tsx index 963d8c6..f7838a5 100644 --- a/thoughts-frontend/app/(auth)/login/page.tsx +++ b/thoughts-frontend/app/(auth)/login/page.tsx @@ -33,7 +33,7 @@ export default function LoginPage() { const form = useForm>({ resolver: zodResolver(LoginSchema), - defaultValues: { username: "", password: "" }, + defaultValues: { email: "", password: "" }, }); async function onSubmit(values: z.infer) { @@ -43,7 +43,7 @@ export default function LoginPage() { setToken(token); router.push("/"); // Redirect to homepage on successful login } catch { - setError("Invalid username or password."); + setError("Invalid email or password."); } } @@ -61,12 +61,12 @@ export default function LoginPage() { {/* ... Form fields for username and password ... */} ( - Username + Email - + diff --git a/thoughts-frontend/app/(auth)/register/layout.tsx b/thoughts-frontend/app/(auth)/register/layout.tsx new file mode 100644 index 0000000..8e40d55 --- /dev/null +++ b/thoughts-frontend/app/(auth)/register/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Join Thoughts", + description: "Create an account on Thoughts and connect across the Fediverse", +}; + +export default function RegisterLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/thoughts-frontend/app/(auth)/register/page.tsx b/thoughts-frontend/app/(auth)/register/page.tsx index 96eaa2f..c83d921 100644 --- a/thoughts-frontend/app/(auth)/register/page.tsx +++ b/thoughts-frontend/app/(auth)/register/page.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { RegisterSchema, registerUser } from "@/lib/api"; +import Cookies from "js-cookie"; import { useState } from "react"; export default function RegisterPage() { @@ -37,9 +38,9 @@ export default function RegisterPage() { async function onSubmit(values: z.infer) { try { setError(null); - await registerUser(values); - // You can automatically log the user in here or just redirect them - router.push("/login"); + const { token } = await registerUser(values); + Cookies.set("auth_token", token, { expires: 7, secure: true }); + router.push("/"); } catch { setError("Username or email may already be taken."); } diff --git a/thoughts-frontend/app/actions/profile.ts b/thoughts-frontend/app/actions/profile.ts new file mode 100644 index 0000000..214fcdf --- /dev/null +++ b/thoughts-frontend/app/actions/profile.ts @@ -0,0 +1,23 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { updateProfile as apiUpdateProfile, UpdateProfileSchema } from "@/lib/api"; +import { z } from "zod"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function updateProfile( + username: string, + data: z.infer +) { + const token = await getToken(); + const updated = await apiUpdateProfile(data, token); + revalidateTag(`profile:${username}`); + revalidateTag("me"); + return updated; +} diff --git a/thoughts-frontend/app/actions/social.ts b/thoughts-frontend/app/actions/social.ts new file mode 100644 index 0000000..adb0f7e --- /dev/null +++ b/thoughts-frontend/app/actions/social.ts @@ -0,0 +1,28 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { + followUser as apiFollowUser, + unfollowUser as apiUnfollowUser, +} from "@/lib/api"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function followUser(username: string) { + const token = await getToken(); + await apiFollowUser(username, token); + revalidateTag(`profile:${username}`); + revalidateTag("feed"); +} + +export async function unfollowUser(username: string) { + const token = await getToken(); + await apiUnfollowUser(username, token); + revalidateTag(`profile:${username}`); + revalidateTag("feed"); +} diff --git a/thoughts-frontend/app/actions/thoughts.ts b/thoughts-frontend/app/actions/thoughts.ts new file mode 100644 index 0000000..6d9e149 --- /dev/null +++ b/thoughts-frontend/app/actions/thoughts.ts @@ -0,0 +1,30 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { + createThought as apiCreateThought, + deleteThought as apiDeleteThought, + CreateThoughtSchema, +} from "@/lib/api"; +import { z } from "zod"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function createThought(data: z.infer) { + const token = await getToken(); + const thought = await apiCreateThought(data, token); + revalidateTag("feed"); + return thought; +} + +export async function deleteThought(thoughtId: string) { + const token = await getToken(); + await apiDeleteThought(thoughtId, token); + revalidateTag("feed"); + revalidateTag(`thought:${thoughtId}`); +} diff --git a/thoughts-frontend/app/layout.tsx b/thoughts-frontend/app/layout.tsx index 9290d85..4771eb7 100644 --- a/thoughts-frontend/app/layout.tsx +++ b/thoughts-frontend/app/layout.tsx @@ -7,8 +7,25 @@ import localFont from "next/font/local"; import InstallPrompt from "@/components/install-prompt"; export const metadata: Metadata = { - title: "Thoughts", - description: "A social network for sharing thoughts", + title: { + default: "Thoughts", + template: "%s · Thoughts", + }, + description: + "A federated social network for short-form thoughts. Follow people across Mastodon, Pixelfed, and the wider Fediverse.", + openGraph: { + type: "website", + siteName: "Thoughts", + title: "Thoughts", + description: + "A federated social network for short-form thoughts. Follow people across the Fediverse.", + }, + twitter: { + card: "summary", + title: "Thoughts", + description: + "A federated social network for short-form thoughts. Follow people across the Fediverse.", + }, }; const frutiger = localFont({ diff --git a/thoughts-frontend/app/loading.tsx b/thoughts-frontend/app/loading.tsx new file mode 100644 index 0000000..55549f4 --- /dev/null +++ b/thoughts-frontend/app/loading.tsx @@ -0,0 +1,20 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function FeedLoading() { + return ( +
+
+
+
+ ); +} diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index 2a64c1d..a4dc23a 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -1,13 +1,8 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { - getFeed, - getFriends, - getMe, - getUserProfile, - Me, - User, -} from "@/lib/api"; -import { PostThoughtForm } from "@/components/post-thought-form"; +import { getFeed, getMe, Me } from "@/lib/api"; +import { ThoughtForm } from "@/components/thought-form"; +import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { PopularTags } from "@/components/popular-tags"; @@ -15,25 +10,26 @@ import { ThoughtThread } from "@/components/thought-thread"; import { buildThoughtThreads } from "@/lib/utils"; import { TopFriends } from "@/components/top-friends"; import { UsersCount } from "@/components/users-count"; - -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { PaginationNav } from "@/components/pagination-nav"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton"; + +export const metadata: Metadata = { + title: "Home", + description: "Your home timeline — thoughts from people you follow", +}; export default async function Home({ searchParams, }: { - searchParams: { page?: string }; + searchParams: Promise<{ page?: string }>; }) { const token = (await cookies()).get("auth_token")?.value ?? null; + const resolvedSearchParams = await searchParams; if (token) { - return ; + return ; } else { return ; } @@ -60,29 +56,26 @@ async function FeedPage({ const { items: allThoughts, totalPages } = feedData!; const thoughtThreads = buildThoughtThreads(allThoughts); - const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; - const userProfiles = await Promise.all( - authors.map((username) => getUserProfile(username, token).catch(() => null)) + const sidebar = ( + <> + }> + + + }> + + + }> + + + ); - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - - const friends = (await getFriends(token)).users.map((user) => user.username); - const shouldDisplayTopFriends = - token && me?.topFriends && me.topFriends.length > 8; - - console.log("Should display top friends:", shouldDisplayTopFriends); - return (
@@ -91,17 +84,10 @@ async function FeedPage({

Your Feed

- +
- - {shouldDisplayTopFriends && ( - - )} - {!shouldDisplayTopFriends && token && friends.length > 0 && ( - - )} - + {sidebar}
@@ -109,44 +95,23 @@ async function FeedPage({ ))} {thoughtThreads.length === 0 && ( -

- Your feed is empty. Follow some users to see their thoughts! -

+ )}
- - - - 1 ? `/?page=${page - 1}` : "#"} - aria-disabled={page <= 1} - /> - - - = totalPages} - /> - - - + `/?page=${p}`} + />
diff --git a/thoughts-frontend/app/remote-actor/page.tsx b/thoughts-frontend/app/remote-actor/page.tsx new file mode 100644 index 0000000..a5e0881 --- /dev/null +++ b/thoughts-frontend/app/remote-actor/page.tsx @@ -0,0 +1,85 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { cookies } from "next/headers"; +import { getMe, getRemoteFollowing, lookupRemoteActor, getRemoteActorPosts, Me } from "@/lib/api"; +import { RemoteUserProfile } from "@/components/remote-user-profile"; + +interface RemoteActorPageProps { + searchParams: Promise<{ handle?: string }>; +} + +function stripHtml(html: string) { + return html.replace(/<[^>]*>/g, "").trim(); +} + +export async function generateMetadata({ + searchParams, +}: RemoteActorPageProps): Promise { + const { handle } = await searchParams; + if (!handle) return { title: "Profile" }; + + const token = (await cookies()).get("auth_token")?.value ?? null; + const actor = await lookupRemoteActor(handle, token).catch(() => null); + if (!actor) return { title: handle }; + + const name = actor.displayName || actor.handle; + const description = actor.bio + ? stripHtml(actor.bio).slice(0, 160) + : `${name} on the Fediverse. Follow from Thoughts.`; + + return { + title: `${name} (${actor.handle})`, + description, + openGraph: { + type: "profile", + title: `${name} (${actor.handle})`, + description, + images: actor.avatarUrl ? [{ url: actor.avatarUrl }] : [], + }, + twitter: { + card: "summary", + title: `${name} · Thoughts`, + description, + images: actor.avatarUrl ? [actor.avatarUrl] : [], + }, + }; +} + +export default async function RemoteActorPage({ + searchParams, +}: RemoteActorPageProps) { + const { handle } = await searchParams; + if (!handle) notFound(); + + const token = (await cookies()).get("auth_token")?.value ?? null; + + const [actorResult, postsResult, meResult, followingResult] = await Promise.allSettled([ + lookupRemoteActor(handle, token), + getRemoteActorPosts(handle, 1, token), + token ? getMe(token) : Promise.resolve(null), + token ? getRemoteFollowing(token) : Promise.resolve([]), + ]); + + if (actorResult.status === "rejected") { + notFound(); + } + + const actor = actorResult.value; + const posts = + postsResult.status === "fulfilled" ? postsResult.value.items : []; + const me = + meResult.status === "fulfilled" ? (meResult.value as Me | null) : null; + const following = + followingResult.status === "fulfilled" ? followingResult.value : []; + const initialFollowed = following.some((f) => f.url === actor.url); + + return ( + + ); +} diff --git a/thoughts-frontend/app/search/loading.tsx b/thoughts-frontend/app/search/loading.tsx new file mode 100644 index 0000000..7b9887b --- /dev/null +++ b/thoughts-frontend/app/search/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function SearchLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/search/page.tsx b/thoughts-frontend/app/search/page.tsx index 99d9296..69b99a6 100644 --- a/thoughts-frontend/app/search/page.tsx +++ b/thoughts-frontend/app/search/page.tsx @@ -1,15 +1,36 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { getMe, search, User } from "@/lib/api"; +import { getMe, search, lookupRemoteActor } from "@/lib/api"; + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ q?: string }>; +}): Promise { + const { q } = await searchParams; + const title = q ? `Search: "${q}"` : "Search"; + return { + title, + description: q + ? `Search results for "${q}" on Thoughts` + : "Search for people and thoughts on Thoughts", + }; +} +import { EmptyState } from "@/components/empty-state"; import { UserListCard } from "@/components/user-list-card"; +import { RemoteUserCard } from "@/components/remote-user-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ThoughtList } from "@/components/thought-list"; +const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; + interface SearchPageProps { - searchParams: { q?: string }; + searchParams: Promise<{ q?: string }>; } export default async function SearchPage({ searchParams }: SearchPageProps) { - const query = searchParams.q || ""; + const { q } = await searchParams; + const query = q || ""; const token = (await cookies()).get("auth_token")?.value ?? null; if (!query) { @@ -23,18 +44,14 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ); } - const [results, me] = await Promise.all([ - search(query, token).catch(() => null), + const isHandle = HANDLE_RE.test(query); + + const [results, remoteActor, me] = await Promise.all([ + isHandle ? null : search(query, token).catch(() => null), + isHandle ? lookupRemoteActor(query, token).catch(() => null) : null, token ? getMe(token).catch(() => null) : null, ]); - const authorDetails = new Map(); - if (results) { - results.users.users.forEach((user: User) => { - authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); - }); - } - return (
@@ -44,31 +61,37 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {

- {results ? ( + {isHandle ? ( + remoteActor ? ( +
+

Remote user

+ +
+ ) : ( + + ) + ) : results ? ( - Thoughts ({results.thoughts.thoughts.length}) + Thoughts ({results.thoughts.length}) - Users ({results.users.users.length}) + Users ({results.users.length}) - + ) : ( -

- No results found or an error occurred. -

+ )}
diff --git a/thoughts-frontend/app/settings/api-keys/page.tsx b/thoughts-frontend/app/settings/api-keys/page.tsx index 629b071..3f1d198 100644 --- a/thoughts-frontend/app/settings/api-keys/page.tsx +++ b/thoughts-frontend/app/settings/api-keys/page.tsx @@ -10,7 +10,7 @@ export default async function ApiKeysPage() { } const initialApiKeys = await getApiKeys(token).catch(() => ({ - apiKeys: [], + keys: [], })); return ( @@ -21,7 +21,7 @@ export default async function ApiKeysPage() { Manage API keys for third-party applications.

- +
); } diff --git a/thoughts-frontend/app/settings/federation/page.tsx b/thoughts-frontend/app/settings/federation/page.tsx new file mode 100644 index 0000000..0d03082 --- /dev/null +++ b/thoughts-frontend/app/settings/federation/page.tsx @@ -0,0 +1,23 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { FederationPanel } from "@/components/federation/federation-panel"; + +export default async function FederationSettingsPage() { + const token = (await cookies()).get("auth_token")?.value; + if (!token) { + redirect("/login"); + } + + return ( +
+
+

Federation

+

+ Manage remote follow requests, followers, and accounts you follow on + other instances. +

+
+ +
+ ); +} diff --git a/thoughts-frontend/app/settings/layout.tsx b/thoughts-frontend/app/settings/layout.tsx index 7054ce5..d7711ab 100644 --- a/thoughts-frontend/app/settings/layout.tsx +++ b/thoughts-frontend/app/settings/layout.tsx @@ -11,6 +11,10 @@ const sidebarNavItems = [ title: "API Keys", href: "/settings/api-keys", }, + { + title: "Federation", + href: "/settings/federation", + }, ]; export default function SettingsLayout({ diff --git a/thoughts-frontend/app/settings/profile/page.tsx b/thoughts-frontend/app/settings/profile/page.tsx index f0d6b12..dbf77df 100644 --- a/thoughts-frontend/app/settings/profile/page.tsx +++ b/thoughts-frontend/app/settings/profile/page.tsx @@ -1,5 +1,11 @@ // app/settings/profile/page.tsx +import type { Metadata } from "next"; import { cookies } from "next/headers"; + +export const metadata: Metadata = { + title: "Edit profile", + description: "Update your Thoughts profile", +}; import { redirect } from "next/navigation"; import { getMe } from "@/lib/api"; import { EditProfileForm } from "@/components/edit-profile-form"; diff --git a/thoughts-frontend/app/tags/[tagName]/loading.tsx b/thoughts-frontend/app/tags/[tagName]/loading.tsx new file mode 100644 index 0000000..30a7a62 --- /dev/null +++ b/thoughts-frontend/app/tags/[tagName]/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function TagLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/tags/[tagName]/page.tsx b/thoughts-frontend/app/tags/[tagName]/page.tsx index 696bb32..953df10 100644 --- a/thoughts-frontend/app/tags/[tagName]/page.tsx +++ b/thoughts-frontend/app/tags/[tagName]/page.tsx @@ -1,17 +1,40 @@ // app/tags/[tagName]/page.tsx +import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api"; +import { getThoughtsByTag, getMe, Me } from "@/lib/api"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ tagName: string }>; +}): Promise { + const { tagName } = await params; + return { + title: `#${tagName}`, + description: `Thoughts tagged with #${tagName}`, + openGraph: { + title: `#${tagName} · Thoughts`, + description: `Thoughts tagged with #${tagName}`, + }, + twitter: { + card: "summary", + title: `#${tagName} · Thoughts`, + description: `Thoughts tagged with #${tagName}`, + }, + }; +} +import { EmptyState } from "@/components/empty-state"; import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { notFound } from "next/navigation"; import { Hash } from "lucide-react"; interface TagPageProps { - params: { tagName: string }; + params: Promise<{ tagName: string }>; } export default async function TagPage({ params }: TagPageProps) { - const { tagName } = params; + const { tagName } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; const [thoughtsResult, meResult] = await Promise.allSettled([ @@ -23,20 +46,10 @@ export default async function TagPage({ params }: TagPageProps) { notFound(); } - const allThoughts = thoughtsResult.value.thoughts; + const allThoughts = thoughtsResult.value.items; const thoughtThreads = buildThoughtThreads(allThoughts); const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; - const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; - const userProfiles = await Promise.all( - authors.map((username) => getUserProfile(username, token).catch(() => null)) - ); - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - return (
@@ -50,14 +63,11 @@ export default async function TagPage({ params }: TagPageProps) { ))} {thoughtThreads.length === 0 && ( -

- No thoughts found for this tag. -

+ )}
diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx new file mode 100644 index 0000000..4fbc58f --- /dev/null +++ b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx @@ -0,0 +1,13 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function ThoughtLoading() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx index 722f474..d205053 100644 --- a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx +++ b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx @@ -1,29 +1,57 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { + getThoughtById, getThoughtThread, - getUserProfile, getMe, Me, - User, ThoughtThread as ThoughtThreadType, } from "@/lib/api"; import { ThoughtThread } from "@/components/thought-thread"; import { notFound } from "next/navigation"; interface ThoughtPageProps { - params: { thoughtId: string }; + params: Promise<{ thoughtId: string }>; } -function collectAuthors(thread: ThoughtThreadType): string[] { - const authors = new Set([thread.authorUsername]); - for (const reply of thread.replies) { - collectAuthors(reply).forEach((author) => authors.add(author)); - } - return Array.from(authors); +function stripHtml(html: string) { + return html.replace(/<[^>]*>/g, "").trim(); +} + +export async function generateMetadata({ + params, +}: ThoughtPageProps): Promise { + const { thoughtId } = await params; + const thought = await getThoughtById(thoughtId, null).catch(() => null); + if (!thought) return { title: "Thought" }; + + const author = thought.author.displayName || thought.author.username; + const preview = stripHtml(thought.content).slice(0, 120); + const description = preview || `A thought by ${author}`; + + return { + title: `${author}: "${preview.slice(0, 60)}${preview.length > 60 ? "…" : ""}"`, + description, + openGraph: { + type: "article", + title: `${author} on Thoughts`, + description, + images: thought.author.avatarUrl + ? [{ url: thought.author.avatarUrl }] + : [], + publishedTime: thought.createdAt.toISOString(), + }, + twitter: { + card: "summary", + title: `${author} on Thoughts`, + description, + images: thought.author.avatarUrl ? [thought.author.avatarUrl] : [], + }, + }; } export default async function ThoughtPage({ params }: ThoughtPageProps) { - const { thoughtId } = params; + const { thoughtId } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; const [threadResult, meResult] = await Promise.allSettled([ @@ -38,20 +66,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) { const thread = threadResult.value; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; - // Fetch details for all authors in the thread efficiently - const authorUsernames = collectAuthors(thread); - const userProfiles = await Promise.all( - authorUsernames.map((username) => - getUserProfile(username, token).catch(() => null) - ) - ); - - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - return (
@@ -60,7 +74,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
diff --git a/thoughts-frontend/app/users/[username]/followers/page.tsx b/thoughts-frontend/app/users/[username]/followers/page.tsx index 4f8e41d..5dda901 100644 --- a/thoughts-frontend/app/users/[username]/followers/page.tsx +++ b/thoughts-frontend/app/users/[username]/followers/page.tsx @@ -1,32 +1,44 @@ import { cookies } from "next/headers"; import { notFound } from "next/navigation"; -import { getFollowersList } from "@/lib/api"; +import { getFollowersList, getMe } from "@/lib/api"; import { UserListCard } from "@/components/user-list-card"; +import { RemoteFollowers } from "@/components/federation/remote-followers"; interface FollowersPageProps { - params: { username: string }; + params: Promise<{ username: string }>; } export default async function FollowersPage({ params }: FollowersPageProps) { - const { username } = params; + const { username } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; - const followersData = await getFollowersList(username, token).catch( - () => null - ); + const [followersData, me] = await Promise.all([ + getFollowersList(username, token).catch(() => null), + token ? getMe(token).catch(() => null) : null, + ]); if (!followersData) { notFound(); } + const isOwnProfile = me?.username === username; + return (

Followers

Users following @{username}.

-
- +
+ + {isOwnProfile && ( +
+

+ Remote followers +

+ +
+ )}
); diff --git a/thoughts-frontend/app/users/[username]/following/page.tsx b/thoughts-frontend/app/users/[username]/following/page.tsx index a12af39..f6b4e72 100644 --- a/thoughts-frontend/app/users/[username]/following/page.tsx +++ b/thoughts-frontend/app/users/[username]/following/page.tsx @@ -1,32 +1,44 @@ import { cookies } from "next/headers"; import { notFound } from "next/navigation"; -import { getFollowingList } from "@/lib/api"; +import { getFollowingList, getMe } from "@/lib/api"; import { UserListCard } from "@/components/user-list-card"; +import { RemoteFollowing } from "@/components/federation/remote-following"; interface FollowingPageProps { - params: { username: string }; + params: Promise<{ username: string }>; } export default async function FollowingPage({ params }: FollowingPageProps) { - const { username } = params; + const { username } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; - const followingData = await getFollowingList(username, token).catch( - () => null - ); + const [followingData, me] = await Promise.all([ + getFollowingList(username, token).catch(() => null), + token ? getMe(token).catch(() => null) : null, + ]); if (!followingData) { notFound(); } + const isOwnProfile = me?.username === username; + return (

Following

Users that @{username} follows.

-
- +
+ + {isOwnProfile && ( +
+

+ Remote following +

+ +
+ )}
); diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index 1962797..8e8c30c 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -1,12 +1,49 @@ +import type { Metadata } from "next"; import { getFollowersList, getFollowingList, - getFriends, getMe, + getRemoteFollowers, + getRemoteFollowing, getUserProfile, getUserThoughts, Me, } from "@/lib/api"; + +interface ProfilePageProps { + params: Promise<{ username: string }>; +} + +export async function generateMetadata({ + params, +}: ProfilePageProps): Promise { + const { username } = await params; + const user = await getUserProfile(username, null).catch(() => null); + if (!user) return { title: username }; + + const name = user.displayName || user.username; + const description = + user.bio || + `Follow ${name} on Thoughts and across the Fediverse.`; + + return { + title: `${name} (@${user.username})`, + description, + openGraph: { + type: "profile", + title: `${name} (@${user.username})`, + description, + images: user.avatarUrl ? [{ url: user.avatarUrl }] : [], + }, + twitter: { + card: "summary", + title: `${name} (@${user.username})`, + description, + images: user.avatarUrl ? [user.avatarUrl] : [], + }, + }; +} +import { EmptyState } from "@/components/empty-state"; import { UserAvatar } from "@/components/user-avatar"; import { Calendar, Settings } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -14,17 +51,21 @@ import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { FollowButton } from "@/components/follow-button"; import { TopFriends } from "@/components/top-friends"; +import { Suspense } from "react"; +import { ProfileSkeleton } from "@/components/loading-skeleton"; import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { Button } from "@/components/ui/button"; import Link from "next/link"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PendingRequests } from "@/components/federation/pending-requests"; interface ProfilePageProps { - params: { username: string }; + params: Promise<{ username: string }>; } export default async function ProfilePage({ params }: ProfilePageProps) { - const { username } = params; + const { username } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; const userProfilePromise = getUserProfile(username, token); @@ -55,33 +96,37 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const thoughts = - thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : []; + thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : []; const thoughtThreads = buildThoughtThreads(thoughts); - const followersCount = + const localFollowersCount = followersResult.status === "fulfilled" - ? followersResult.value.users.length + ? followersResult.value.total : 0; - const followingCount = + const localFollowingCount = followingResult.status === "fulfilled" - ? followingResult.value.users.length + ? followingResult.value.total : 0; const isOwnProfile = me?.username === user.username; - const isFollowing = - me?.following?.some( - (followedUser) => followedUser.username === user.username - ) || false; - const authorDetails = new Map(); - authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); + const [remoteFollowersCount, remoteFollowingCount] = + isOwnProfile && token + ? await Promise.all([ + getRemoteFollowers(token).then((r) => r.length).catch(() => 0), + getRemoteFollowing(token).then((r) => r.length).catch(() => 0), + ]) + : [0, 0]; - const friends = - typeof token === "string" - ? (await getFriends(token)).users.map((user) => user.username) - : []; + const followersCount = localFollowersCount + remoteFollowersCount; + const followingCount = localFollowingCount + remoteFollowingCount; + const isFollowing = user.isFollowedByViewer; - const shouldDisplayTopFriends = token && friends.length > 8; + const apiDomain = process.env.NEXT_PUBLIC_API_URL + ? new URL(process.env.NEXT_PUBLIC_API_URL).hostname + : null; + const fediverseHandle = + user.local && apiDomain ? `@${user.username}@${apiDomain}` : null; return (
@@ -148,6 +193,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) { > @{user.username}

+ {fediverseHandle && ( +

+ {fediverseHandle} +

+ )}

- Joined {new Date(user.joinedAt).toLocaleDateString()} + Joined {user.joinedAt ? new Date(user.joinedAt).toLocaleDateString() : "Unknown"}

- {shouldDisplayTopFriends && ( - - )} - {token && } + }> + +
@@ -205,24 +254,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) { id="profile-card__thoughts" className="col-span-1 lg:col-span-3 space-y-4" > - {thoughtThreads.map((thought) => ( - - ))} - {thoughtThreads.length === 0 && ( - -

- This user hasn't posted any public thoughts yet. -

-
- )} + + + Thoughts + {isOwnProfile && ( + Requests + )} + + + {thoughtThreads.map((thought) => ( + + ))} + {thoughtThreads.length === 0 && ( + + )} + + {isOwnProfile && ( + + + + )} + diff --git a/thoughts-frontend/app/users/all/page.tsx b/thoughts-frontend/app/users/all/page.tsx index 077b3ca..5dd8c64 100644 --- a/thoughts-frontend/app/users/all/page.tsx +++ b/thoughts-frontend/app/users/all/page.tsx @@ -1,19 +1,14 @@ import { getAllUsers } from "@/lib/api"; import { UserListCard } from "@/components/user-list-card"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { PaginationNav } from "@/components/pagination-nav"; export default async function AllUsersPage({ searchParams, }: { - searchParams: { page?: string }; + searchParams: Promise<{ page?: string }>; }) { - const page = parseInt(searchParams.page ?? "1", 10); + const { page: pageStr } = await searchParams; + const page = parseInt(pageStr ?? "1", 10); const usersData = await getAllUsers(page).catch(() => null); if (!usersData) { @@ -27,7 +22,8 @@ export default async function AllUsersPage({ ); } - const { items, totalPages } = usersData; + const { items, total, per_page } = usersData; + const totalPages = Math.ceil(total / per_page); return (
@@ -39,24 +35,11 @@ export default async function AllUsersPage({
- {totalPages > 1 && ( - - - - 1 ? `/users/all?page=${page - 1}` : "#"} - aria-disabled={page <= 1} - /> - - - = totalPages} - /> - - - - )} + `/users/all?page=${p}`} + />
); diff --git a/thoughts-frontend/components/api-keys-list.tsx b/thoughts-frontend/components/api-keys-list.tsx index 7fd8486..ba1368b 100644 --- a/thoughts-frontend/components/api-keys-list.tsx +++ b/thoughts-frontend/components/api-keys-list.tsx @@ -64,7 +64,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) { try { const newKeyResponse = await createApiKey(values, token); setKeys((prev) => [...prev, newKeyResponse]); - setNewKey(newKeyResponse.plaintextKey ?? null); + setNewKey(newKeyResponse.key ?? null); form.reset(); toast.success("API Key created successfully."); } catch { @@ -113,7 +113,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) { {`Created on ${format(key.createdAt, "PPP")}`}

- {`${key.keyPrefix}...`} + {key.id}

diff --git a/thoughts-frontend/components/edit-profile-form.tsx b/thoughts-frontend/components/edit-profile-form.tsx index 53874b7..13c90cf 100644 --- a/thoughts-frontend/components/edit-profile-form.tsx +++ b/thoughts-frontend/components/edit-profile-form.tsx @@ -3,9 +3,8 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; -import { Me, UpdateProfileSchema, updateProfile } from "@/lib/api"; +import { Me, UpdateProfileSchema } from "@/lib/api"; +import { updateProfile } from "@/app/actions/profile"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; @@ -16,19 +15,15 @@ import { FormLabel, FormControl, FormMessage, - FormDescription, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { TopFriendsCombobox } from "@/components/top-friends-combobox"; interface EditProfileFormProps { currentUser: Me; } export function EditProfileForm({ currentUser }: EditProfileFormProps) { - const router = useRouter(); - const { token } = useAuth(); const form = useForm>({ resolver: zodResolver(UpdateProfileSchema), @@ -38,18 +33,14 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) { avatarUrl: currentUser.avatarUrl ?? undefined, headerUrl: currentUser.headerUrl ?? undefined, customCss: currentUser.customCss ?? undefined, - topFriends: currentUser.topFriends ?? [], }, }); async function onSubmit(values: z.infer) { - if (!token) return; toast.info("Updating your profile..."); try { - await updateProfile(values, token); + await updateProfile(currentUser.username, values); toast.success("Profile updated successfully!"); - router.push(`/users/${currentUser.username}`); - router.refresh(); } catch (err) { toast.error(`Failed to update profile. ${err}`); } @@ -135,25 +126,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) { )} /> - ( - - Top Friends - - - - - Select up to 8 of your friends to display on your profile. - - - - )} - /> + + + + ))} + + ); +} diff --git a/thoughts-frontend/components/federation/remote-followers.tsx b/thoughts-frontend/components/federation/remote-followers.tsx new file mode 100644 index 0000000..62fa64f --- /dev/null +++ b/thoughts-frontend/components/federation/remote-followers.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getRemoteFollowers, rejectFollowRequest, type RemoteActor } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; +import { UserAvatar } from "@/components/user-avatar"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import Link from "next/link"; +import { fullFediverseHandle } from "@/lib/utils"; + +export function RemoteFollowers() { + const { token } = useAuth(); + const [followers, setFollowers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!token) return; + getRemoteFollowers(token) + .then(setFollowers) + .catch(() => toast.error("Failed to load followers")) + .finally(() => setLoading(false)); + }, [token]); + + const remove = async (actorUrl: string) => { + if (!token) return; + setFollowers((prev) => prev.filter((f) => f.url !== actorUrl)); + await rejectFollowRequest(actorUrl, token).catch(() => { + toast.error("Failed to remove follower"); + }); + }; + + if (loading) return

Loading…

; + if (followers.length === 0) + return

No remote followers yet.

; + + return ( +
    + {followers.map((actor) => ( +
  • + + +
    +

    + {actor.displayName || actor.handle} +

    +

    + @{fullFediverseHandle(actor.handle, actor.url)} +

    +
    + + +
  • + ))} +
+ ); +} diff --git a/thoughts-frontend/components/federation/remote-following.tsx b/thoughts-frontend/components/federation/remote-following.tsx new file mode 100644 index 0000000..d39e955 --- /dev/null +++ b/thoughts-frontend/components/federation/remote-following.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getRemoteFollowing, unfollowRemoteActor, type RemoteActor } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; +import { UserAvatar } from "@/components/user-avatar"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import Link from "next/link"; +import { fullFediverseHandle } from "@/lib/utils"; + +export function RemoteFollowing() { + const { token } = useAuth(); + const [following, setFollowing] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!token) return; + getRemoteFollowing(token) + .then(setFollowing) + .catch(() => toast.error("Failed to load following")) + .finally(() => setLoading(false)); + }, [token]); + + const unfollow = async (actor: RemoteActor) => { + if (!token) return; + const handle = fullFediverseHandle(actor.handle, actor.url); + setFollowing((prev) => prev.filter((f) => f.url !== actor.url)); + await unfollowRemoteActor(handle, token).catch(() => { + toast.error("Failed to unfollow"); + }); + }; + + if (loading) return

Loading…

; + if (following.length === 0) + return

Not following anyone remotely yet.

; + + return ( +
    + {following.map((actor) => ( +
  • + + +
    +

    + {actor.displayName || actor.handle} +

    +

    + @{fullFediverseHandle(actor.handle, actor.url)} +

    +
    + + +
  • + ))} +
+ ); +} diff --git a/thoughts-frontend/components/follow-button.tsx b/thoughts-frontend/components/follow-button.tsx index 0462601..494292c 100644 --- a/thoughts-frontend/components/follow-button.tsx +++ b/thoughts-frontend/components/follow-button.tsx @@ -1,66 +1,41 @@ -"use client"; +"use client" -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; -import { followUser, unfollowUser } from "@/lib/api"; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; -import { UserPlus, UserMinus } from "lucide-react"; +import { useOptimistic } from "react" +import { followUser, unfollowUser } from "@/app/actions/social" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { UserPlus, UserMinus } from "lucide-react" interface FollowButtonProps { - username: string; - isInitiallyFollowing: boolean; + username: string + isInitiallyFollowing: boolean } -export function FollowButton({ - username, - isInitiallyFollowing, -}: FollowButtonProps) { - const [isFollowing, setIsFollowing] = useState(isInitiallyFollowing); - const [isLoading, setIsLoading] = useState(false); - const { token } = useAuth(); - const router = useRouter(); - - const handleClick = async () => { - if (!token) { - toast.error("You must be logged in to follow users."); - return; - } - - setIsLoading(true); - const action = isFollowing ? unfollowUser : followUser; +export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) { + const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing) + async function handleClick() { + const next = !optimisticFollowing + setOptimisticFollowing(next) try { - // Optimistic update - setIsFollowing(!isFollowing); - await action(username, token); - router.refresh(); // Re-fetch server component data to get the latest follower count etc. + await (next ? followUser(username) : unfollowUser(username)) } catch { - // Revert on error - setIsFollowing(isFollowing); - toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`); - } finally { - setIsLoading(false); + setOptimisticFollowing(!next) // revert + toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`) } - }; + } return ( - ); + ) } diff --git a/thoughts-frontend/components/loading-skeleton.tsx b/thoughts-frontend/components/loading-skeleton.tsx new file mode 100644 index 0000000..8e6a729 --- /dev/null +++ b/thoughts-frontend/components/loading-skeleton.tsx @@ -0,0 +1,57 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +export function ThoughtSkeleton() { + return ( + + + +
+ + +
+
+ + + + +
+ ) +} + +export function ProfileSkeleton() { + return ( + + + +
+ + +
+
+
+ ) +} + +export function TagsSkeleton() { + return ( + + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ) +} + +export function CountSkeleton() { + return ( + + + + + + ) +} diff --git a/thoughts-frontend/components/pagination-nav.tsx b/thoughts-frontend/components/pagination-nav.tsx new file mode 100644 index 0000000..18b350a --- /dev/null +++ b/thoughts-frontend/components/pagination-nav.tsx @@ -0,0 +1,76 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface Props { + page: number; + totalPages: number; + buildHref: (page: number) => string; +} + +function pageNumbers( + page: number, + totalPages: number +): (number | "ellipsis")[] { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const pages: (number | "ellipsis")[] = [1]; + + if (page > 3) pages.push("ellipsis"); + + const start = Math.max(2, page - 1); + const end = Math.min(totalPages - 1, page + 1); + for (let i = start; i <= end; i++) pages.push(i); + + if (page < totalPages - 2) pages.push("ellipsis"); + + pages.push(totalPages); + + return pages; +} + +export function PaginationNav({ page, totalPages, buildHref }: Props) { + if (totalPages <= 1) return null; + + return ( + + + + 1 ? buildHref(page - 1) : "#"} + aria-disabled={page <= 1} + /> + + + {pageNumbers(page, totalPages).map((p, i) => + p === "ellipsis" ? ( + + + + ) : ( + + + {p} + + + ) + )} + + + = totalPages} + /> + + + + ); +} diff --git a/thoughts-frontend/components/post-thought-form.tsx b/thoughts-frontend/components/post-thought-form.tsx deleted file mode 100644 index ae32f71..0000000 --- a/thoughts-frontend/components/post-thought-form.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Form, - FormField, - FormItem, - FormControl, - FormMessage, -} from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { CreateThoughtSchema, createThought } from "@/lib/api"; -import { useAuth } from "@/hooks/use-auth"; -import { toast } from "sonner"; -import { Globe, Lock, Users } from "lucide-react"; -import { useState } from "react"; -import { Confetti } from "./confetti"; - -export function PostThoughtForm() { - const router = useRouter(); - const { token } = useAuth(); - const [showConfetti, setShowConfetti] = useState(false); - - const form = useForm>({ - resolver: zodResolver(CreateThoughtSchema), - defaultValues: { content: "", visibility: "Public" }, - }); - - async function onSubmit(values: z.infer) { - if (!token) { - toast.error("You must be logged in to post."); - return; - } - - try { - await createThought(values, token); - toast.success("Your thought has been posted!"); - setShowConfetti(true); - form.reset(); - router.refresh(); // This is the key to updating the feed - } catch { - toast.error("Failed to post thought. Please try again."); - } - } - - return ( - <> - setShowConfetti(false)} /> - - -
- - ( - - -