feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
24
.env.example
24
.env.example
@@ -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
|
||||
|
||||
@@ -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
24
.gitea/workflows/lint.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
37
.gitea/workflows/test.yml
Normal file
37
.gitea/workflows/test.yml
Normal 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
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependency compilation separately from source
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
|
||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
||||
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
|
||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# Now copy real source and build
|
||||
COPY crates ./crates
|
||||
|
||||
RUN cargo build --release -p bootstrap -p worker
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/thoughts ./thoughts
|
||||
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
CMD ["./thoughts"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gabriel Kaszewski
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Thoughts
|
||||
|
||||
A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend.
|
||||
|
||||
## Features
|
||||
|
||||
- Short-form posts (thoughts) with replies, boosts, and likes
|
||||
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync
|
||||
- Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes
|
||||
- Async event fan-out via NATS — notifications and AP delivery run in a separate worker process
|
||||
- JWT authentication (Bearer token)
|
||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||
- Top friends — pin up to 5 users as highlighted contacts
|
||||
- API keys for third-party client access
|
||||
- Home feed, public feed, and per-user thought timelines
|
||||
|
||||
## Architecture
|
||||
|
||||
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||
|
||||
```
|
||||
domain — pure types and port trait definitions, no external deps
|
||||
application — use cases and event processing services (business logic)
|
||||
api-types — shared REST API request/response DTOs
|
||||
presentation — Axum HTTP router, OpenAPI spec, composition root for the API process
|
||||
bootstrap — binary: thoughts (API server)
|
||||
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
||||
adapters/
|
||||
auth — JWT issuance and validation, Argon2 password hashing
|
||||
postgres — PostgreSQL repositories for all domain entities
|
||||
postgres-search — PostgreSQL trigram full-text search
|
||||
postgres-federation — PostgreSQL-backed federation repository
|
||||
activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware
|
||||
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
|
||||
nats — NATS transport implementing Transport + MessageSource ports
|
||||
event-payload — shared event serialization DTOs
|
||||
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust stable (1.80+)
|
||||
- PostgreSQL 15+
|
||||
- NATS (optional — federation and notifications still work without it, events queue in-process)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your values:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
|
||||
JWT_SECRET=change-me
|
||||
BASE_URL=http://localhost:3000
|
||||
NATS_URL=nats://localhost:4222 # optional
|
||||
```
|
||||
|
||||
See `.env.example` for all available options.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# API server (runs migrations automatically on startup)
|
||||
cargo run -p bootstrap
|
||||
|
||||
# Event worker — federation fan-out and notifications (separate terminal)
|
||||
cargo run -p worker
|
||||
```
|
||||
|
||||
Both processes share the same PostgreSQL database. The worker is optional but required for ActivityPub delivery to remote servers.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# Unit tests — no database required
|
||||
cargo test -p application
|
||||
|
||||
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
|
||||
|
||||
## API
|
||||
|
||||
All REST endpoints are under the root path. Authentication uses `Authorization: Bearer <token>` obtained from `POST /auth/login`.
|
||||
|
||||
Interactive API documentation is available at runtime:
|
||||
|
||||
- **Swagger UI** — `http://localhost:3000/docs`
|
||||
- **Scalar** — `http://localhost:3000/scalar`
|
||||
|
||||
## Docker
|
||||
|
||||
The image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers:
|
||||
|
||||
```bash
|
||||
docker build -t thoughts .
|
||||
|
||||
# API server
|
||||
docker run -p 3000:3000 \
|
||||
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||
-e JWT_SECRET=change-me \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
thoughts
|
||||
|
||||
# Event worker (same image, different entrypoint)
|
||||
docker run \
|
||||
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
--entrypoint ./thoughts-worker \
|
||||
thoughts
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE).
|
||||
Reference in New Issue
Block a user