feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
7 changed files with 328 additions and 33 deletions
Showing only changes of commit 057f10cb69 - Show all commits

View File

@@ -1,3 +1,21 @@
POSTGRES_USER=thoughts_user
POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts_db
# 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
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

View File

@@ -1,41 +1,59 @@
name: Build and Deploy Thoughts
name: deploy
on:
push:
branches:
- master
workflow_dispatch:
branches: [master]
tags: ["v*"]
env:
REGISTRY: git.gabrielkaszewski.dev
IMAGE: git.gabrielkaszewski.dev/gkaszewski/thoughts
jobs:
build-and-deploy-local:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- uses: actions/checkout@v4
- 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: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- 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: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Deploy with Docker Compose
run: |
docker compose -f compose.prod.yml down
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
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
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker pull ${{ env.IMAGE }}:latest
docker compose -f /opt/thoughts/docker-compose.yml up -d

24
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,24 @@
name: lint
on:
push:
branches: ["**"]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings

37
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,37 @@
name: test
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
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: test
run: cargo test --workspace

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ----- build -----
FROM rust:slim-bookworm AS builder
WORKDIR /build
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps without real source
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN cargo fetch
# Now copy real source and build
COPY crates ./crates
RUN cargo build --release -p bootstrap -p worker
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/thoughts ./thoughts
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
EXPOSE 3000
ENV RUST_LOG=info
CMD ["./thoughts"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# Thoughts
A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend.
## Features
- Short-form posts (thoughts) with replies, boosts, and likes
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync
- Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes
- Async event fan-out via NATS — notifications and AP delivery run in a separate worker process
- JWT authentication (Bearer token)
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
- Full-text search over thoughts and users via PostgreSQL trigram indexes
- Top friends — pin up to 5 users as highlighted contacts
- API keys for third-party client access
- Home feed, public feed, and per-user thought timelines
## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
domain — pure types and port trait definitions, no external deps
application — use cases and event processing services (business logic)
api-types — shared REST API request/response DTOs
presentation — Axum HTTP router, OpenAPI spec, composition root for the API process
bootstrap — binary: thoughts (API server)
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
adapters/
auth — JWT issuance and validation, Argon2 password hashing
postgres — PostgreSQL repositories for all domain entities
postgres-search — PostgreSQL trigram full-text search
postgres-federation — PostgreSQL-backed federation repository
activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
nats — NATS transport implementing Transport + MessageSource ports
event-payload — shared event serialization DTOs
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
```
## Prerequisites
- Rust stable (1.80+)
- PostgreSQL 15+
- NATS (optional — federation and notifications still work without it, events queue in-process)
## Environment Variables
Copy `.env.example` to `.env` and fill in your values:
```env
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
JWT_SECRET=change-me
BASE_URL=http://localhost:3000
NATS_URL=nats://localhost:4222 # optional
```
See `.env.example` for all available options.
## Run
```bash
# API server (runs migrations automatically on startup)
cargo run -p bootstrap
# Event worker — federation fan-out and notifications (separate terminal)
cargo run -p worker
```
Both processes share the same PostgreSQL database. The worker is optional but required for ActivityPub delivery to remote servers.
## Test
```bash
# Unit tests — no database required
cargo test -p application
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
cargo test --workspace
```
The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
## API
All REST endpoints are under the root path. Authentication uses `Authorization: Bearer <token>` obtained from `POST /auth/login`.
Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs`
- **Scalar** — `http://localhost:3000/scalar`
## Docker
The image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers:
```bash
docker build -t thoughts .
# API server
docker run -p 3000:3000 \
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
-e JWT_SECRET=change-me \
-e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \
thoughts
# Event worker (same image, different entrypoint)
docker run \
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
-e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \
--entrypoint ./thoughts-worker \
thoughts
```
## License
MIT License. See [LICENSE](LICENSE).