diff --git a/.env.example b/.env.example index a56f5fe..92675a3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,21 @@ -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 +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..e59a8c8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 \ No newline at end of file + 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 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..0ea059d --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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 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..9854c00 --- /dev/null +++ b/README.md @@ -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 ` 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).