diff --git a/.env.example b/.env.example
index d15d6c4..849966e 100644
--- a/.env.example
+++ b/.env.example
@@ -24,5 +24,21 @@ RUST_ENV=development # set to "production" to disable AP debug mode
# but events will not be delivered to the worker)
# NATS_URL=nats://localhost:4222
+# Media storage — local filesystem (default) or S3/MinIO
+STORAGE_BACKEND=local
+STORAGE_PATH=./media # required when STORAGE_BACKEND=local
+# STORAGE_PREFIX= # optional key prefix
+
+# S3/MinIO (set STORAGE_BACKEND=s3 to use)
+# S3_ENDPOINT=http://localhost:9000
+# S3_ACCESS_KEY_ID=minioadmin
+# S3_SECRET_ACCESS_KEY=minioadmin
+# S3_BUCKET=thoughts
+# S3_REGION=us-east-1
+
+# Upload limits (optional, defaults shown)
+# UPLOAD_MAX_BYTES=5242880
+# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif
+
# Logging
RUST_LOG=info
diff --git a/.gitignore b/.gitignore
index cc50778..33e1262 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
.env
/target
+/docs/superpowers/
+
+/media
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 4b07b14..a7a6c01 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,7 +14,7 @@ dependencies = [
"domain",
"futures",
"k-ap",
- "reqwest",
+ "reqwest 0.13.3",
"serde",
"serde_json",
"tokio",
@@ -47,12 +47,12 @@ dependencies = [
"http-signature-normalization",
"http-signature-normalization-reqwest",
"httpdate",
- "itertools",
+ "itertools 0.14.0",
"moka",
"pin-project-lite",
"rand 0.8.6",
"regex",
- "reqwest",
+ "reqwest 0.13.3",
"reqwest-middleware",
"rsa",
"serde",
@@ -275,8 +275,10 @@ version = "0.1.0"
dependencies = [
"activitypub",
"async-trait",
+ "bytes",
"chrono",
"domain",
+ "futures",
"hex",
"sha2",
"thiserror 2.0.18",
@@ -469,6 +471,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
+ "multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@@ -580,6 +583,7 @@ name = "bootstrap"
version = "0.1.0"
dependencies = [
"activitypub",
+ "application",
"async-nats",
"async-trait",
"auth",
@@ -595,6 +599,7 @@ dependencies = [
"postgres-search",
"presentation",
"sqlx",
+ "storage",
"tokio",
"tower-http",
"tower_governor",
@@ -1040,6 +1045,7 @@ name = "domain"
version = "0.1.0"
dependencies = [
"async-trait",
+ "bytes",
"chrono",
"futures",
"hex",
@@ -1623,7 +1629,7 @@ dependencies = [
"base64",
"http-signature-normalization",
"httpdate",
- "reqwest",
+ "reqwest 0.13.3",
"reqwest-middleware",
"sha2",
"tokio",
@@ -1641,6 +1647,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "humantime"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+
[[package]]
name = "hyper"
version = "1.9.0"
@@ -1673,6 +1685,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
+ "rustls-native-certs",
"tokio",
"tokio-rustls",
"tower-service",
@@ -1888,6 +1901,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1992,7 +2014,7 @@ dependencies = [
[[package]]
name = "k-ap"
version = "0.1.0"
-source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.2#767b1e69d4f384093ea33d72d5aa46ff140f5ac8"
+source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.3#7901b29f7c09415e82f7f098f89c1df6b86bbfd3"
dependencies = [
"activitypub_federation",
"anyhow",
@@ -2001,7 +2023,7 @@ dependencies = [
"chrono",
"enum_delegate",
"futures",
- "reqwest",
+ "reqwest 0.13.3",
"serde",
"serde_json",
"tokio",
@@ -2187,6 +2209,23 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http 1.4.0",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "nats"
version = "0.1.0"
@@ -2311,6 +2350,36 @@ dependencies = [
"libm",
]
+[[package]]
+name = "object_store"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf"
+dependencies = [
+ "async-trait",
+ "base64",
+ "bytes",
+ "chrono",
+ "futures",
+ "humantime",
+ "hyper",
+ "itertools 0.13.0",
+ "md-5",
+ "parking_lot",
+ "percent-encoding",
+ "quick-xml",
+ "rand 0.8.6",
+ "reqwest 0.12.28",
+ "ring",
+ "serde",
+ "serde_json",
+ "snafu",
+ "tokio",
+ "tracing",
+ "url",
+ "walkdir",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2534,6 +2603,7 @@ dependencies = [
"axum",
"chrono",
"domain",
+ "futures",
"http-body-util",
"serde",
"serde_json",
@@ -2582,6 +2652,16 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2797,6 +2877,48 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http 1.4.0",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams 0.4.2",
+ "web-sys",
+]
+
[[package]]
name = "reqwest"
version = "0.13.3"
@@ -2836,7 +2958,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
+ "wasm-streams 0.5.0",
"web-sys",
]
@@ -2849,7 +2971,7 @@ dependencies = [
"anyhow",
"async-trait",
"http 1.4.0",
- "reqwest",
+ "reqwest 0.13.3",
"thiserror 2.0.18",
"tower-service",
]
@@ -3289,6 +3411,27 @@ dependencies = [
"serde",
]
+[[package]]
+name = "snafu"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
+dependencies = [
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "socket2"
version = "0.5.10"
@@ -3541,6 +3684,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "storage"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "bytes",
+ "domain",
+ "futures",
+ "object_store",
+ "tokio",
+]
+
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -4290,6 +4446,19 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasm-streams"
version = "0.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index 7829fed..db5cac5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ members = [
"crates/adapters/nats",
"crates/adapters/event-payload",
"crates/adapters/event-transport",
+ "crates/adapters/storage",
]
resolver = "2"
@@ -29,9 +30,10 @@ 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"] }
+axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
futures = "0.3"
+bytes = "1.0"
dotenvy = "0.15"
async-nats = "0.48"
async-stream = "0.3"
@@ -50,3 +52,4 @@ auth = { path = "crates/adapters/auth" }
nats = { path = "crates/adapters/nats" }
event-payload = { path = "crates/adapters/event-payload" }
event-transport = { path = "crates/adapters/event-transport" }
+storage = { path = "crates/adapters/storage" }
diff --git a/Dockerfile b/Dockerfile
index 7830f15..464ad86 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,8 +6,8 @@ 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/storage/Cargo.toml crates/adapters/storage/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
@@ -35,7 +35,7 @@ RUN cargo fetch
# Now copy real source and build
COPY crates ./crates
-RUN cargo build --release -p bootstrap -p worker
+RUN cargo build --release -p bootstrap -p worker --features storage/s3
# ----- runtime -----
FROM debian:bookworm-slim
diff --git a/README.md b/README.md
index a05aa63..5a0610e 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ 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
@@ -75,6 +76,10 @@ adapters/
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+)
@@ -105,6 +110,16 @@ Copy `.env.example` to `.env` and fill in your values.
| `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
@@ -167,6 +182,9 @@ docker run -p 8000:8000 \
-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)
diff --git a/compose.yml b/compose.yml
index 06cf13a..f9bbc79 100644
--- a/compose.yml
+++ b/compose.yml
@@ -32,6 +32,10 @@ services:
BASE_URL: http://localhost:8000
NATS_URL: nats://nats:4222
RUST_LOG: info
+ STORAGE_BACKEND: local
+ STORAGE_PATH: /data/media
+ volumes:
+ - media_data:/data/media
depends_on:
postgres:
condition: service_healthy
@@ -65,3 +69,4 @@ services:
volumes:
postgres_data:
+ media_data:
diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml
index 3ad9899..523eb26 100644
--- a/crates/adapters/activitypub/Cargo.toml
+++ b/crates/adapters/activitypub/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
-k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
+k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }
diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs
index 120d8e1..91164cf 100644
--- a/crates/adapters/activitypub/src/handler.rs
+++ b/crates/adapters/activitypub/src/handler.rs
@@ -10,9 +10,9 @@ use url::Url;
use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls;
-use k_ap::ApObjectHandler;
use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
+use k_ap::ApObjectHandler;
pub struct ThoughtsObjectHandler {
repo: Arc,
diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs
index 837dc26..a99a754 100644
--- a/crates/adapters/activitypub/src/lib.rs
+++ b/crates/adapters/activitypub/src/lib.rs
@@ -6,6 +6,8 @@ pub mod urls;
pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote;
-pub use port::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
+pub use port::{
+ AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
+};
pub use service::ApFederationAdapter;
pub use urls::ThoughtsUrls;
diff --git a/crates/adapters/activitypub/src/note/mod.rs b/crates/adapters/activitypub/src/note/mod.rs
index 8bd9eb0..9ec3e63 100644
--- a/crates/adapters/activitypub/src/note/mod.rs
+++ b/crates/adapters/activitypub/src/note/mod.rs
@@ -1,6 +1,6 @@
+use chrono::{DateTime, Utc};
use k_ap::NoteType;
use k_ap::AS_PUBLIC;
-use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs
index 2403b29..810bae8 100644
--- a/crates/adapters/activitypub/src/port.rs
+++ b/crates/adapters/activitypub/src/port.rs
@@ -55,7 +55,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
- -> Result
-
+
);
}
diff --git a/thoughts-frontend/components/edit-profile-form.tsx b/thoughts-frontend/components/edit-profile-form.tsx
index 13c90cf..4e07bb1 100644
--- a/thoughts-frontend/components/edit-profile-form.tsx
+++ b/thoughts-frontend/components/edit-profile-form.tsx
@@ -1,9 +1,11 @@
"use client";
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
-import { Me, UpdateProfileSchema } from "@/lib/api";
+import { Me, UpdateProfileSchema, uploadAvatar, uploadBanner } from "@/lib/api";
import { updateProfile } from "@/app/actions/profile";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -18,20 +20,63 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
+import { UserAvatar } from "@/components/user-avatar";
+import { Camera, ImagePlus, Loader2 } from "lucide-react";
interface EditProfileFormProps {
currentUser: Me;
+ token: string;
}
-export function EditProfileForm({ currentUser }: EditProfileFormProps) {
+export function EditProfileForm({ currentUser, token }: EditProfileFormProps) {
+ const router = useRouter();
+ const avatarInputRef = useRef(null);
+ const bannerInputRef = useRef(null);
+
+ const [avatarSrc, setAvatarSrc] = useState(currentUser.avatarUrl ?? null);
+ const [bannerSrc, setBannerSrc] = useState(currentUser.headerUrl ?? null);
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
+ const [uploadingBanner, setUploadingBanner] = useState(false);
+
+ async function handleAvatarChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploadingAvatar(true);
+ try {
+ const updated = await uploadAvatar(file, token);
+ setAvatarSrc(updated.avatarUrl ?? null);
+ router.refresh();
+ toast.success("Avatar updated");
+ } catch {
+ toast.error("Failed to upload avatar");
+ } finally {
+ setUploadingAvatar(false);
+ e.target.value = "";
+ }
+ }
+
+ async function handleBannerChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploadingBanner(true);
+ try {
+ const updated = await uploadBanner(file, token);
+ setBannerSrc(updated.headerUrl ?? null);
+ router.refresh();
+ toast.success("Banner updated");
+ } catch {
+ toast.error("Failed to upload banner");
+ } finally {
+ setUploadingBanner(false);
+ e.target.value = "";
+ }
+ }
const form = useForm>({
resolver: zodResolver(UpdateProfileSchema),
defaultValues: {
displayName: currentUser.displayName ?? undefined,
bio: currentUser.bio ?? undefined,
- avatarUrl: currentUser.avatarUrl ?? undefined,
- headerUrl: currentUser.headerUrl ?? undefined,
customCss: currentUser.customCss ?? undefined,
},
});
@@ -51,6 +96,78 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {