Gabriel Kaszewski 84edf58de6
Some checks failed
lint / lint (push) Failing after 9m26s
test / unit (push) Successful in 16m3s
fix(federation): fix 27 AP bugs, gaps, and inconsistencies
Round 1 — 18 bug fixes:
- remote likes/boosts now persist in engagement tables
- intern_remote_actor uses name@domain, expanded username to VARCHAR(255)
- PgRemoteActorRepository upsert/find now handles all fields
- update_following_status no longer a no-op, count_followers counts all
- accept/reject follow publishes event before DB mark (atomicity)
- fetch_outbox_page follows pagination via next links
- actor URL canonicalized to /users/{uuid}
- content_to_html escapes single quotes
- WebFinger accepts application/ld+json type
- try_from_ap accepts Article and Page object types
- feed SQL uses parameterized viewer UUID instead of format!
- content cap raised from 500 to 5000 chars
- also_known_as changed from Option<String> to Vec<String>
- connections fetch always triggers from page 1

Round 2 — 9 gap fixes:
- on_announce_removed handler deletes boost row on Undo(Announce)
- on_update handles Person/Service/Group actor profile updates
- sync_remote_actor_to_user syncs remote_actors → users on create/update
- FederationBlockPort: block_by_username sends Block activity to remote
- domain RemoteActor gains inbox_url, shared_inbox_url fields
- remote_actors attachment column (JSONB) with read/write
- .well-known/host-meta endpoint
- 256KB body limit on AP inbox routes
- outbox cleanup job (7-day retention, hourly sweep)
2026-05-29 11:28:40 +02:00

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) with API key support for third-party clients
  • 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
  • Home feed, public feed, and per-user thought timelines
  • Rate limiting and registration control

Federation

Thoughts implements the ActivityPub protocol, making it compatible with Mastodon, Misskey, Pleroma, and other Fediverse software.

Fediverse endpoints

Endpoint Description
GET /.well-known/webfinger WebFinger discovery (?resource=acct:user@host)
GET /.well-known/nodeinfo NodeInfo pointer
GET /nodeinfo/2.0 NodeInfo 2.0 — software metadata
GET /users/{username} Actor profile (content-negotiated: JSON-LD or REST)
GET /users/{username}/outbox Paginated outbox of Note activities
POST /users/{username}/inbox Per-actor inbox
POST /inbox Shared inbox for bulk delivery

Federation flow

  1. A remote user follows @you@yourinstance.com → Mastodon sends a Follow activity to /users/you/inbox
  2. Thoughts accepts and delivers an Accept back to the remote actor's inbox
  3. When you post, Thoughts fans out a Create(Note) activity to all remote followers via the NATS worker
  4. Remote posts from people you follow are fetched, cached, and shown in your home feed

Without NATS

Federation still works without NATS — activities are processed in-process synchronously. The worker is required for async fan-out delivery to remote servers at scale. See Environment Variables.

Instance moderation

  • Domain blocks — block an entire instance; no activities are delivered to or accepted from blocked domains
  • Actor blocks — block individual remote actors; a Block activity is delivered and they are filtered from all feeds

Architecture

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
  storage              — object storage adapter (local filesystem + S3/MinIO) implementing the MediaStore port
  postgres             — PostgreSQL repositories for all domain entities
  postgres-search      — PostgreSQL trigram full-text search
  postgres-federation  — PostgreSQL-backed federation repository
  k-ap (external)      — generic AP protocol layer (ActivityPubService, actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, HTTP signatures)
  activitypub          — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
  nats                 — NATS transport implementing Transport + MessageSource ports
  event-payload        — shared event serialization DTOs
  event-transport      — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter

The domain and application crates have zero concrete adapter dependencies. All I/O goes through &dyn Port traits, keeping business logic fully testable with in-memory fakes.

Media Storage

Users can upload avatar and banner images via PUT /users/me/avatar and PUT /users/me/banner (multipart/form-data). Uploaded images are served at GET /media/*path (public, no auth required). Set STORAGE_BACKEND to configure the backend.

Prerequisites

  • Rust stable (1.80+)
  • PostgreSQL 15+
  • NATS with JetStream (optional — see Without NATS)

Environment Variables

Copy .env.example to .env and fill in your values.

Required

Variable Description
DATABASE_URL PostgreSQL connection string
JWT_SECRET Secret used to sign JWT tokens — use a long random string in production
BASE_URL Public URL of the API server — used for ActivityPub actor URLs and canonical links

Optional

Variable Default Description
HOST 0.0.0.0 Interface to bind
PORT 3000 Port to listen on
NATS_URL NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker
CORS_ORIGINS * Comma-separated allowed origins for CORS, e.g. https://app.example.com
RATE_LIMIT disabled Max requests per minute per IP
ALLOW_REGISTRATION true Set to false to close sign-ups
RUST_ENV development Set to production to disable ActivityPub debug logging
RUST_LOG info Log level filter (error, warn, info, debug, trace)
STORAGE_BACKEND local Storage backend: local or s3
STORAGE_PATH Local filesystem path for media (required when STORAGE_BACKEND=local)
STORAGE_PREFIX Optional key prefix for all stored objects
S3_ENDPOINT S3/MinIO endpoint URL (required when STORAGE_BACKEND=s3)
S3_ACCESS_KEY_ID S3 access key (required when STORAGE_BACKEND=s3)
S3_SECRET_ACCESS_KEY S3 secret key (required when STORAGE_BACKEND=s3)
S3_BUCKET S3 bucket name (required when STORAGE_BACKEND=s3)
S3_REGION us-east-1 S3 region
UPLOAD_MAX_BYTES 5242880 Max upload size in bytes (default 5 MiB)
UPLOAD_ALLOWED_TYPES image/jpeg,image/png,image/gif,image/webp,image/avif Comma-separated allowed MIME types

Run

# 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

# 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 UIhttp://localhost:8000/docs
  • Scalarhttp://localhost:8000/scalar

Frontend

The Next.js frontend lives in thoughts-frontend/. It requires two environment variables:

NEXT_PUBLIC_API_URL=http://localhost:8000        # client-side requests
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000  # SSR requests
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:

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 \
  -e STORAGE_BACKEND=local \
  -e STORAGE_PATH=/data/media \
  -v media_vol:/data/media \
  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

Local development stack

compose.yml spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.

docker compose up

Services:

Service Port Description
postgres 5432 PostgreSQL 16
nats 4222 / 8222 NATS with JetStream; 8222 is the monitoring endpoint
api 8000 Thoughts API server
worker Event worker (no exposed port)
frontend 3000 Next.js frontend

Contributing

Contributions are welcome. A few guidelines:

  • Run tests before opening a PR. At minimum: cargo test -p application (no database needed). For adapter changes: cargo test --workspace with a live database.
  • Keep the hexagonal boundary. domain and application must not import any adapter crate. Use &dyn Port traits for all I/O.
  • No ORM. The project uses raw sqlx. Keep it that way.
  • ActivityPub changes — test against a live Mastodon instance if possible, or use the AP debug logs (RUST_ENV=development).
  • Small, focused PRs are easier to review than large ones.

For significant changes, open an issue first to discuss the approach.

License

MIT License. See LICENSE.

Description
Nostalgic social platform with Frutiger Aero style. 128-char posts, custom CSS profiles, and full ActivityPub federation — interoperable with Mastodon, Misskey, and Movies Diary.
https://thoughts.gabrielkaszewski.dev/ Readme MIT 7.8 MiB
Languages
Rust 59.1%
TypeScript 38.9%
CSS 1.4%
Dockerfile 0.3%
Shell 0.1%
Other 0.1%