From 01932cf3378d4484484c2fca0e2d933f5a4cc9a2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 24 May 2026 02:06:47 +0200 Subject: [PATCH] feat: add image upload for avatar and banner --- .env.example | 16 ++ .gitignore | 3 + Cargo.lock | 185 +++++++++++++- Cargo.toml | 5 +- Dockerfile | 4 +- README.md | 18 ++ compose.yml | 5 + crates/adapters/activitypub/Cargo.toml | 2 +- crates/adapters/activitypub/src/handler.rs | 2 +- crates/adapters/activitypub/src/lib.rs | 4 +- crates/adapters/activitypub/src/note/mod.rs | 2 +- crates/adapters/activitypub/src/port.rs | 4 +- crates/adapters/activitypub/src/service.rs | 80 +++--- .../adapters/postgres-federation/Cargo.toml | 2 +- crates/adapters/postgres/src/user/mod.rs | 9 +- crates/adapters/storage/Cargo.toml | 18 ++ crates/adapters/storage/src/adapter.rs | 237 ++++++++++++++++++ crates/adapters/storage/src/config.rs | 67 +++++ crates/adapters/storage/src/lib.rs | 5 + crates/application/Cargo.toml | 2 + .../application/src/use_cases/profile/mod.rs | 151 ++++++++++- .../src/use_cases/profile/tests.rs | 217 ++++++++++++++++ crates/bootstrap/Cargo.toml | 4 +- crates/bootstrap/src/config.rs | 29 +++ crates/bootstrap/src/factory.rs | 33 ++- crates/domain/Cargo.toml | 1 + crates/domain/src/ports.rs | 12 + crates/domain/src/testing/mod.rs | 20 +- crates/presentation/Cargo.toml | 1 + crates/presentation/src/handlers/media.rs | 51 ++++ crates/presentation/src/handlers/mod.rs | 1 + crates/presentation/src/handlers/users/mod.rs | 88 ++++++- crates/presentation/src/routes.rs | 11 +- crates/presentation/src/state.rs | 4 + crates/presentation/src/testing.rs | 21 +- crates/worker/Cargo.toml | 2 +- crates/worker/src/factory.rs | 12 +- .../app/settings/profile/page.tsx | 2 +- .../components/edit-profile-form.tsx | 157 +++++++++--- thoughts-frontend/lib/api.ts | 21 +- 40 files changed, 1396 insertions(+), 112 deletions(-) create mode 100644 crates/adapters/storage/Cargo.toml create mode 100644 crates/adapters/storage/src/adapter.rs create mode 100644 crates/adapters/storage/src/config.rs create mode 100644 crates/adapters/storage/src/lib.rs create mode 100644 crates/presentation/src/handlers/media.rs 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, DomainError>; + -> Result, DomainError>; /// Ensure a remote actor placeholder exists; create one if absent. /// Idempotent — safe to call multiple times with the same URL. @@ -100,7 +100,7 @@ pub trait ActivityPubRepository: Send + Sync { /// Return the AP actor URL and inbox URL for a user, if stored. /// Returns None for users that have not been federated. async fn get_actor_ap_urls(&self, user_id: &UserId) - -> Result, DomainError>; + -> Result, DomainError>; } #[async_trait] diff --git a/crates/adapters/activitypub/src/service.rs b/crates/adapters/activitypub/src/service.rs index 672f4ba..4a34776 100644 --- a/crates/adapters/activitypub/src/service.rs +++ b/crates/adapters/activitypub/src/service.rs @@ -146,12 +146,14 @@ async fn resolve_actor_profiles_from_urls( let display_name = resp["name"].as_str().map(|s| s.to_string()); let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string()); - Some(domain::models::actor_connection_summary::ActorConnectionSummary { - url: ap_url, - handle, - display_name, - avatar_url, - }) + Some( + domain::models::actor_connection_summary::ActorConnectionSummary { + url: ap_url, + handle, + display_name, + avatar_url, + }, + ) } let futs: Vec<_> = urls.into_iter().map(fetch_one).collect(); @@ -254,7 +256,13 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { let user_uuid = author_user_id.as_uuid(); let ap_id = self.actor_ap_id(user_uuid); let followers_url = self.actor_followers_url(user_uuid); - let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url); + let note = build_note_json( + thought, + &ap_id, + &followers_url, + self.base_url(), + in_reply_to_url, + ); self.inner .broadcast_create_note(user_uuid, note) .await @@ -266,8 +274,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { author_user_id: &UserId, thought_ap_id: &str, ) -> Result<(), DomainError> { - let ap_id = url::Url::parse(thought_ap_id) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let ap_id = + url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?; self.inner .broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id) .await @@ -284,7 +292,13 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { let user_uuid = author_user_id.as_uuid(); let ap_id = self.actor_ap_id(user_uuid); let followers_url = self.actor_followers_url(user_uuid); - let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url); + let note = build_note_json( + thought, + &ap_id, + &followers_url, + self.base_url(), + in_reply_to_url, + ); self.inner .broadcast_update_note(user_uuid, note) .await @@ -296,8 +310,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { booster_user_id: &UserId, object_ap_id: &str, ) -> Result<(), DomainError> { - let ap_id = url::Url::parse(object_ap_id) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let ap_id = + url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?; self.inner .broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id) .await @@ -309,8 +323,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { booster_user_id: &UserId, object_ap_id: &str, ) -> Result<(), DomainError> { - let ap_id = url::Url::parse(object_ap_id) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let ap_id = + url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?; self.inner .broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id) .await @@ -323,10 +337,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { object_ap_id: &str, author_inbox_url: &str, ) -> Result<(), DomainError> { - let object = url::Url::parse(object_ap_id) - .map_err(|e| DomainError::Internal(e.to_string()))?; - let inbox = url::Url::parse(author_inbox_url) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let object = + url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?; + let inbox = + url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?; self.inner .broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox) .await @@ -339,10 +353,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter { object_ap_id: &str, author_inbox_url: &str, ) -> Result<(), DomainError> { - let object = url::Url::parse(object_ap_id) - .map_err(|e| DomainError::Internal(e.to_string()))?; - let inbox = url::Url::parse(author_inbox_url) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let object = + url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?; + let inbox = + url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?; self.inner .broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox) .await @@ -435,8 +449,7 @@ impl FederationSchedulerPort for ApFederationAdapter { let empty = vec![]; let items = val["orderedItems"].as_array().unwrap_or(&empty); for item in items { - let actor_url = - item.as_str().or_else(|| item["id"].as_str()).unwrap_or(""); + let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or(""); if !actor_url.is_empty() { all_urls.push(actor_url.to_string()); } @@ -490,9 +503,9 @@ impl FederationSchedulerPort for ApFederationAdapter { impl FederationLookupPort for ApFederationAdapter { async fn lookup_actor(&self, handle: &str) -> Result { let normalized = handle.trim_start_matches('@'); - let at = normalized.rfind('@').ok_or_else(|| { - DomainError::InvalidInput("handle must be user@domain".into()) - })?; + let at = normalized + .rfind('@') + .ok_or_else(|| DomainError::InvalidInput("handle must be user@domain".into()))?; let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]); let wf_url = format!( @@ -532,8 +545,10 @@ impl FederationLookupPort for ApFederationAdapter { .map_err(|e| DomainError::ExternalService(e.to_string()))?; let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string(); - let preferred_username = - actor_json["preferredUsername"].as_str().unwrap_or("").to_string(); + let preferred_username = actor_json["preferredUsername"] + .as_str() + .unwrap_or("") + .to_string(); let domain_part = url::Url::parse(&ap_url) .ok() .and_then(|u| u.host_str().map(|s| s.to_string())) @@ -645,10 +660,9 @@ impl FederationFetchPort for ApFederationAdapter { return None; } - let published = - DateTime::parse_from_rfc3339(note["published"].as_str()?) - .ok()? - .with_timezone(&chrono::Utc); + let published = DateTime::parse_from_rfc3339(note["published"].as_str()?) + .ok()? + .with_timezone(&chrono::Utc); let text = note["content"].as_str().unwrap_or("").to_string(); let has_attachments = note["attachment"] diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index c8582b5..954640a 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/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" } sqlx = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/postgres/src/user/mod.rs b/crates/adapters/postgres/src/user/mod.rs index 6c4fd67..69e59b7 100644 --- a/crates/adapters/postgres/src/user/mod.rs +++ b/crates/adapters/postgres/src/user/mod.rs @@ -268,7 +268,14 @@ impl UserWriter for PgUserRepository { input: UpdateProfileInput, ) -> Result<(), DomainError> { sqlx::query( - "UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1" + "UPDATE users SET \ + display_name = COALESCE($2, display_name), \ + bio = COALESCE($3, bio), \ + avatar_url = COALESCE($4, avatar_url), \ + header_url = COALESCE($5, header_url), \ + custom_css = COALESCE($6, custom_css), \ + updated_at = NOW() \ + WHERE id = $1", ) .bind(user_id.as_uuid()) .bind(input.display_name) diff --git a/crates/adapters/storage/Cargo.toml b/crates/adapters/storage/Cargo.toml new file mode 100644 index 0000000..998a312 --- /dev/null +++ b/crates/adapters/storage/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "storage" +version = "0.1.0" +edition = "2021" + +[features] +s3 = ["object_store/aws"] + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +anyhow = { workspace = true } +object_store = { version = "0.11" } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } diff --git a/crates/adapters/storage/src/adapter.rs b/crates/adapters/storage/src/adapter.rs new file mode 100644 index 0000000..7d50225 --- /dev/null +++ b/crates/adapters/storage/src/adapter.rs @@ -0,0 +1,237 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{DataStream, MediaStore}, +}; +use futures::stream::StreamExt; +use object_store::{path::Path, Error as OsError, ObjectStore}; +use std::sync::Arc; + +pub struct ObjectStorageAdapter { + store: Arc, + prefix: String, +} + +fn validate_key(key: &str) -> Result<(), DomainError> { + if key.is_empty() { + return Err(DomainError::InvalidInput( + "storage key must not be empty".into(), + )); + } + if key.starts_with('/') { + return Err(DomainError::InvalidInput(format!( + "storage key must not start with '/': {key}" + ))); + } + if key.split('/').any(|seg| seg == ".." || seg == ".") { + return Err(DomainError::InvalidInput(format!( + "storage key contains invalid path segment: {key}" + ))); + } + Ok(()) +} + +fn map_os_err(e: OsError) -> DomainError { + match e { + OsError::NotFound { .. } => DomainError::NotFound, + e => DomainError::Internal(e.to_string()), + } +} + +impl ObjectStorageAdapter { + pub fn new( + store: Arc, + prefix: impl Into, + ) -> Result { + let prefix = prefix.into(); + if !prefix.is_empty() { + validate_key(&prefix)?; + } + Ok(Self { store, prefix }) + } + + fn path(&self, key: &str) -> Path { + if self.prefix.is_empty() { + Path::from(key) + } else { + Path::from(format!("{}/{key}", self.prefix)) + } + } +} + +#[async_trait] +impl MediaStore for ObjectStorageAdapter { + async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError> { + validate_key(key)?; + let path = self.path(key); + let mut upload = self + .store + .put_multipart(&path) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + let mut stream = data; + while let Some(result) = stream.next().await { + match result { + Ok(bytes) => { + if let Err(e) = upload.put_part(bytes.into()).await { + let _ = upload.abort().await; + return Err(DomainError::Internal(e.to_string())); + } + } + Err(e) => { + let _ = upload.abort().await; + return Err(e); + } + } + } + upload + .complete() + .await + .map(|_| ()) + .map_err(|e| DomainError::Internal(e.to_string())) + } + + async fn get(&self, key: &str) -> Result { + validate_key(key)?; + let path = self.path(key); + let result = self.store.get(&path).await.map_err(map_os_err)?; + let s = result + .into_stream() + .map(|r| r.map_err(|e| DomainError::Internal(e.to_string()))); + Ok(Box::pin(s)) + } + + async fn delete(&self, key: &str) -> Result<(), DomainError> { + validate_key(key)?; + let path = self.path(key); + match self.store.delete(&path).await { + Ok(()) => Ok(()), + Err(OsError::NotFound { .. }) => Ok(()), + Err(e) => Err(DomainError::Internal(e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::stream; + use object_store::memory::InMemory; + + fn make_adapter() -> ObjectStorageAdapter { + ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap() + } + + fn one_shot(data: &'static [u8]) -> DataStream { + Box::pin(stream::once(async move { Ok(Bytes::from(data)) })) + } + + #[tokio::test] + async fn put_get_roundtrip() { + let a = make_adapter(); + a.put("hello.txt", one_shot(b"world")).await.unwrap(); + let mut s = a.get("hello.txt").await.unwrap(); + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(out, b"world"); + } + + #[tokio::test] + async fn get_missing_is_not_found() { + let a = make_adapter(); + assert!(matches!( + a.get("nope.txt").await, + Err(DomainError::NotFound) + )); + } + + #[tokio::test] + async fn delete_is_idempotent() { + let a = make_adapter(); + a.delete("nope.txt").await.unwrap(); + } + + #[tokio::test] + async fn delete_removes_key() { + let a = make_adapter(); + a.put("file.txt", one_shot(b"data")).await.unwrap(); + a.delete("file.txt").await.unwrap(); + assert!(matches!( + a.get("file.txt").await, + Err(DomainError::NotFound) + )); + } + + #[tokio::test] + async fn put_overwrites_existing() { + let a = make_adapter(); + a.put("file.txt", one_shot(b"v1")).await.unwrap(); + a.put("file.txt", one_shot(b"v2")).await.unwrap(); + let mut s = a.get("file.txt").await.unwrap(); + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(out, b"v2"); + } + + #[tokio::test] + async fn rejects_empty_key() { + let a = make_adapter(); + assert!(matches!( + a.put("", one_shot(b"x")).await, + Err(DomainError::InvalidInput(_)) + )); + assert!(matches!(a.get("").await, Err(DomainError::InvalidInput(_)))); + assert!(matches!( + a.delete("").await, + Err(DomainError::InvalidInput(_)) + )); + } + + #[tokio::test] + async fn rejects_absolute_key() { + let a = make_adapter(); + assert!(matches!( + a.put("/etc/passwd", one_shot(b"x")).await, + Err(DomainError::InvalidInput(_)) + )); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let a = make_adapter(); + assert!(matches!( + a.get("../escape").await, + Err(DomainError::InvalidInput(_)) + )); + assert!(matches!( + a.get("a/../../../etc").await, + Err(DomainError::InvalidInput(_)) + )); + } + + #[test] + fn new_rejects_traversal_prefix() { + assert!(matches!( + ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil"), + Err(DomainError::InvalidInput(_)) + )); + } + + #[test] + fn new_rejects_absolute_prefix() { + assert!(matches!( + ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root"), + Err(DomainError::InvalidInput(_)) + )); + } + + #[test] + fn new_accepts_empty_prefix() { + assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok()); + } +} diff --git a/crates/adapters/storage/src/config.rs b/crates/adapters/storage/src/config.rs new file mode 100644 index 0000000..c3c7244 --- /dev/null +++ b/crates/adapters/storage/src/config.rs @@ -0,0 +1,67 @@ +use anyhow::{Context, Result}; +use object_store::local::LocalFileSystem; +use object_store::ObjectStore; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct StorageConfig { + pub backend: String, + pub local_path: Option, + pub s3_endpoint: Option, + pub s3_access_key_id: Option, + pub s3_secret_access_key: Option, + pub s3_bucket: Option, + pub s3_region: Option, +} + +pub fn build_store(config: &StorageConfig) -> Result> { + match config.backend.as_str() { + "local" => { + let path = config + .local_path + .as_deref() + .context("STORAGE_PATH must be set when STORAGE_BACKEND=local")?; + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create storage dir: {path}"))?; + let store = LocalFileSystem::new_with_prefix(path)?; + Ok(Arc::new(store)) + } + #[cfg(feature = "s3")] + "s3" => { + use object_store::aws::AmazonS3Builder; + let store = AmazonS3Builder::new() + .with_endpoint( + config + .s3_endpoint + .as_deref() + .context("S3_ENDPOINT must be set")?, + ) + .with_access_key_id( + config + .s3_access_key_id + .as_deref() + .context("S3_ACCESS_KEY_ID must be set")?, + ) + .with_secret_access_key( + config + .s3_secret_access_key + .as_deref() + .context("S3_SECRET_ACCESS_KEY must be set")?, + ) + .with_bucket_name( + config + .s3_bucket + .as_deref() + .context("S3_BUCKET must be set")?, + ) + .with_region(config.s3_region.as_deref().unwrap_or("us-east-1")) + .with_allow_http(true) + .build()?; + Ok(Arc::new(store)) + } + other => anyhow::bail!( + "unknown STORAGE_BACKEND={other:?}; supported: local{}", + if cfg!(feature = "s3") { ", s3" } else { "" }, + ), + } +} diff --git a/crates/adapters/storage/src/lib.rs b/crates/adapters/storage/src/lib.rs new file mode 100644 index 0000000..f32d0d1 --- /dev/null +++ b/crates/adapters/storage/src/lib.rs @@ -0,0 +1,5 @@ +pub mod adapter; +pub mod config; + +pub use adapter::ObjectStorageAdapter; +pub use config::{build_store, StorageConfig}; diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 83b1812..21efe37 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -15,6 +15,8 @@ hex = "0.4" tracing = { workspace = true } url = { workspace = true } tokio = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/crates/application/src/use_cases/profile/mod.rs b/crates/application/src/use_cases/profile/mod.rs index 791be0c..4b0bec3 100644 --- a/crates/application/src/use_cases/profile/mod.rs +++ b/crates/application/src/use_cases/profile/mod.rs @@ -1,5 +1,6 @@ const MAX_TOP_FRIENDS: usize = 8; +use bytes::Bytes; use domain::{ errors::DomainError, events::DomainEvent, @@ -7,7 +8,9 @@ use domain::{ top_friend::TopFriend, user::{UpdateProfileInput, User}, }, - ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter}, + ports::{ + EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter, + }, value_objects::{UserId, Username}, }; @@ -81,5 +84,151 @@ pub async fn set_top_friends( top_friends.set_top_friends(user_id, friends).await } +#[derive(Clone)] +pub struct UploadConfig { + pub max_bytes: usize, + pub allowed_content_types: Vec, +} + +impl Default for UploadConfig { + fn default() -> Self { + Self { + max_bytes: 5 * 1024 * 1024, + allowed_content_types: vec![ + "image/jpeg".into(), + "image/png".into(), + "image/gif".into(), + "image/webp".into(), + "image/avif".into(), + ], + } + } +} + +fn mime_to_ext(mime: &str) -> Result<&'static str, DomainError> { + match mime { + "image/jpeg" => Ok("jpg"), + "image/png" => Ok("png"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/avif" => Ok("avif"), + _ => Err(DomainError::InvalidInput("unsupported content type".into())), + } +} + +#[allow(clippy::too_many_arguments)] +async fn store_image( + media: &dyn MediaStore, + base_url: &str, + cfg: &UploadConfig, + content_type: &str, + data: Bytes, + user_id: &UserId, + key_segment: &str, + old_url: Option<&str>, +) -> Result { + if !cfg.allowed_content_types.iter().any(|t| t == content_type) { + return Err(DomainError::InvalidInput("unsupported content type".into())); + } + if data.len() > cfg.max_bytes { + return Err(DomainError::InvalidInput("file too large".into())); + } + let ext = mime_to_ext(content_type)?; + if let Some(old) = old_url { + let prefix = format!("{base_url}/media/"); + if let Some(old_key) = old.strip_prefix(&prefix) { + media.delete(old_key).await?; + } + } + let key = format!("users/{}/{key_segment}.{ext}", user_id.as_uuid()); + let stream = Box::pin(futures::stream::once(async move { Ok(data) })); + media.put(&key, stream).await?; + Ok(key) +} + +#[allow(clippy::too_many_arguments)] +pub async fn upload_avatar( + users: &dyn UserRepository, + media: &dyn MediaStore, + events: &dyn EventPublisher, + user_id: &UserId, + base_url: &str, + cfg: &UploadConfig, + content_type: &str, + data: Bytes, +) -> Result<(), DomainError> { + let current = users + .find_by_id(user_id) + .await? + .ok_or(DomainError::NotFound)?; + let key = store_image( + media, + base_url, + cfg, + content_type, + data, + user_id, + "avatar", + current.avatar_url.as_deref(), + ) + .await?; + users + .update_profile( + user_id, + UpdateProfileInput { + avatar_url: Some(format!("{base_url}/media/{key}")), + ..Default::default() + }, + ) + .await?; + events + .publish(&DomainEvent::ProfileUpdated { + user_id: user_id.clone(), + }) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn upload_banner( + users: &dyn UserRepository, + media: &dyn MediaStore, + events: &dyn EventPublisher, + user_id: &UserId, + base_url: &str, + cfg: &UploadConfig, + content_type: &str, + data: Bytes, +) -> Result<(), DomainError> { + let current = users + .find_by_id(user_id) + .await? + .ok_or(DomainError::NotFound)?; + let key = store_image( + media, + base_url, + cfg, + content_type, + data, + user_id, + "banner", + current.header_url.as_deref(), + ) + .await?; + users + .update_profile( + user_id, + UpdateProfileInput { + header_url: Some(format!("{base_url}/media/{key}")), + ..Default::default() + }, + ) + .await?; + events + .publish(&DomainEvent::ProfileUpdated { + user_id: user_id.clone(), + }) + .await +} + #[cfg(test)] mod tests; diff --git a/crates/application/src/use_cases/profile/tests.rs b/crates/application/src/use_cases/profile/tests.rs index 9f0fdd4..913fefb 100644 --- a/crates/application/src/use_cases/profile/tests.rs +++ b/crates/application/src/use_cases/profile/tests.rs @@ -5,6 +5,7 @@ use domain::{ testing::TestStore, value_objects::{Email, PasswordHash, UserId, Username}, }; +use std::sync::{Arc, Mutex}; fn make_user() -> User { User::new_local( @@ -64,3 +65,219 @@ async fn get_user_by_username_returns_correct_user() { let found = get_user_by_username(&store, "alice").await.unwrap(); assert_eq!(found.id, user.id); } + +// ── upload tests ────────────────────────────────────────────────────────────── + +use bytes::Bytes; +use domain::ports::{DataStream, MediaStore}; +use std::collections::HashMap; + +#[derive(Default, Clone)] +struct MockMedia { + store: Arc>>, + deleted: Arc>>, +} + +#[async_trait::async_trait] +impl MediaStore for MockMedia { + async fn put(&self, key: &str, mut data: DataStream) -> Result<(), DomainError> { + use futures::stream::StreamExt; + let mut buf = Vec::new(); + while let Some(chunk) = data.next().await { + buf.extend_from_slice(&chunk?); + } + self.store + .lock() + .unwrap() + .insert(key.to_string(), Bytes::from(buf)); + Ok(()) + } + async fn get(&self, key: &str) -> Result { + let bytes = self + .store + .lock() + .unwrap() + .get(key) + .cloned() + .ok_or(DomainError::NotFound)?; + Ok(Box::pin(futures::stream::once(async move { Ok(bytes) }))) + } + async fn delete(&self, key: &str) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(key); + self.deleted.lock().unwrap().push(key.to_string()); + Ok(()) + } +} + +fn default_cfg() -> UploadConfig { + UploadConfig::default() +} + +#[tokio::test] +async fn upload_avatar_rejects_unsupported_mime() { + let store = TestStore::default(); + let media = MockMedia::default(); + let user = make_user(); + store.users.lock().unwrap().push(user.clone()); + let err = upload_avatar( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "text/plain", + Bytes::from("hi"), + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::InvalidInput(_))); +} + +#[tokio::test] +async fn upload_avatar_rejects_oversized_data() { + let store = TestStore::default(); + let media = MockMedia::default(); + let user = make_user(); + store.users.lock().unwrap().push(user.clone()); + let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]); + let err = upload_avatar( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "image/jpeg", + big, + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::InvalidInput(_))); +} + +#[tokio::test] +async fn upload_avatar_stores_file_and_updates_url() { + let store = TestStore::default(); + let media = MockMedia::default(); + let user = make_user(); + store.users.lock().unwrap().push(user.clone()); + upload_avatar( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "image/jpeg", + Bytes::from("img"), + ) + .await + .unwrap(); + let key = format!("users/{}/avatar.jpg", user.id.as_uuid()); + assert!(media.store.lock().unwrap().contains_key(&key)); + let saved = store + .users + .lock() + .unwrap() + .iter() + .find(|u| u.id == user.id) + .unwrap() + .clone(); + assert_eq!( + saved.avatar_url, + Some(format!("http://localhost/media/{key}")) + ); +} + +#[tokio::test] +async fn upload_avatar_deletes_old_file_on_reupload() { + let store = TestStore::default(); + let media = MockMedia::default(); + let mut user = make_user(); + let old_key = format!("users/{}/avatar.png", user.id.as_uuid()); + user.avatar_url = Some(format!("http://localhost/media/{old_key}")); + store.users.lock().unwrap().push(user.clone()); + media + .store + .lock() + .unwrap() + .insert(old_key.clone(), Bytes::from("old")); + upload_avatar( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "image/jpeg", + Bytes::from("new"), + ) + .await + .unwrap(); + assert!(!media.store.lock().unwrap().contains_key(&old_key)); + assert!(media.deleted.lock().unwrap().contains(&old_key)); +} + +#[tokio::test] +async fn upload_banner_stores_file_and_updates_header_url() { + let store = TestStore::default(); + let media = MockMedia::default(); + let user = make_user(); + store.users.lock().unwrap().push(user.clone()); + upload_banner( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "image/png", + Bytes::from("banner"), + ) + .await + .unwrap(); + let key = format!("users/{}/banner.png", user.id.as_uuid()); + assert!(media.store.lock().unwrap().contains_key(&key)); + let saved = store + .users + .lock() + .unwrap() + .iter() + .find(|u| u.id == user.id) + .unwrap() + .clone(); + assert_eq!( + saved.header_url, + Some(format!("http://localhost/media/{key}")) + ); +} + +#[tokio::test] +async fn upload_banner_deletes_old_file_on_reupload() { + let store = TestStore::default(); + let media = MockMedia::default(); + let mut user = make_user(); + let old_key = format!("users/{}/banner.jpg", user.id.as_uuid()); + user.header_url = Some(format!("http://localhost/media/{old_key}")); + store.users.lock().unwrap().push(user.clone()); + media + .store + .lock() + .unwrap() + .insert(old_key.clone(), Bytes::from("old")); + upload_banner( + &store, + &media, + &store, + &user.id, + "http://localhost", + &default_cfg(), + "image/png", + Bytes::from("new"), + ) + .await + .unwrap(); + assert!(!media.store.lock().unwrap().contains_key(&old_key)); + assert!(media.deleted.lock().unwrap().contains(&old_key)); +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 6e5e220..dbd151b 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -14,10 +14,12 @@ postgres = { workspace = true } postgres-search = { workspace = true } postgres-federation = { workspace = true } activitypub = { workspace = true } -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" } nats = { workspace = true } event-transport = { workspace = true } auth = { workspace = true } +storage = { workspace = true } +application = { workspace = true } sqlx = { workspace = true } async-nats = { workspace = true } async-trait = { workspace = true } diff --git a/crates/bootstrap/src/config.rs b/crates/bootstrap/src/config.rs index 89141fc..8991cf1 100644 --- a/crates/bootstrap/src/config.rs +++ b/crates/bootstrap/src/config.rs @@ -11,6 +11,18 @@ pub struct Config { pub host: String, pub cors_origins: String, pub rate_limit: Option, + // Storage + pub storage_backend: String, + pub storage_path: Option, + pub storage_prefix: String, + pub s3_endpoint: Option, + pub s3_access_key_id: Option, + pub s3_secret_access_key: Option, + pub s3_bucket: Option, + pub s3_region: Option, + // Upload limits + pub upload_max_bytes: usize, + pub upload_allowed_types: Vec, } impl Config { @@ -36,6 +48,23 @@ impl Config { rate_limit: std::env::var("RATE_LIMIT") .ok() .and_then(|v| v.parse().ok()), + storage_backend: std::env::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".into()), + storage_path: std::env::var("STORAGE_PATH").ok(), + storage_prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(), + s3_endpoint: std::env::var("S3_ENDPOINT").ok(), + s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(), + s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(), + s3_bucket: std::env::var("S3_BUCKET").ok(), + s3_region: std::env::var("S3_REGION").ok(), + upload_max_bytes: std::env::var("UPLOAD_MAX_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5 * 1024 * 1024), + upload_allowed_types: std::env::var("UPLOAD_ALLOWED_TYPES") + .unwrap_or_else(|_| "image/jpeg,image/png,image/gif,image/webp,image/avif".into()) + .split(',') + .map(|s| s.trim().to_string()) + .collect(), } } } diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index a15883e..e2a6e51 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -5,8 +5,10 @@ use async_trait::async_trait; use sqlx::PgPool; use std::sync::Arc; +use application::use_cases::profile::UploadConfig; +use storage::{build_store, ObjectStorageAdapter, StorageConfig}; + use activitypub::{ApFederationAdapter, ThoughtsObjectHandler}; -use k_ap::ActivityPubService; use auth::ApiKeyServiceImpl; use domain::{ errors::DomainError, @@ -14,6 +16,7 @@ use domain::{ ports::{EventPublisher, OutboxWriter}, }; use event_transport::EventPublisherAdapter; +use k_ap::ActivityPubService; use nats::NatsTransport; use postgres::activitypub::PgActivityPubRepository; use postgres::engagement::PgEngagementRepository; @@ -72,8 +75,7 @@ pub async fn build(cfg: &Config) -> Infrastructure { }; // 3. ActivityPub federation - let connections_repo = - Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())); + let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())); let raw_ap_service = Arc::new( ActivityPubService::builder( Arc::new(PostgresFederationRepository::new(pool.clone())), @@ -98,7 +100,27 @@ pub async fn build(cfg: &Config) -> Infrastructure { ); let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo)); - // 4. Application state + // 4. Storage adapter + let storage_cfg = StorageConfig { + backend: cfg.storage_backend.clone(), + local_path: cfg.storage_path.clone(), + s3_endpoint: cfg.s3_endpoint.clone(), + s3_access_key_id: cfg.s3_access_key_id.clone(), + s3_secret_access_key: cfg.s3_secret_access_key.clone(), + s3_bucket: cfg.s3_bucket.clone(), + s3_region: cfg.s3_region.clone(), + }; + let object_store = build_store(&storage_cfg).expect("Failed to build object store"); + let media_adapter: Arc = Arc::new( + ObjectStorageAdapter::new(object_store, cfg.storage_prefix.clone()) + .expect("Failed to create storage adapter"), + ); + let upload_config = UploadConfig { + max_bytes: cfg.upload_max_bytes, + allowed_content_types: cfg.upload_allowed_types.clone(), + }; + + // 5. Application state let state = AppState { users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), @@ -140,6 +162,9 @@ pub async fn build(cfg: &Config) -> Infrastructure { postgres::api_key::PgApiKeyRepository::new(pool.clone()), ))), engagement: Arc::new(PgEngagementRepository::new(pool.clone())), + media: media_adapter, + upload_config, + base_url: cfg.base_url.clone(), }; Infrastructure { state, ap_service } diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index ab7acae..a6bab93 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -14,6 +14,7 @@ chrono = { workspace = true } serde = { workspace = true } futures = { workspace = true } url = { workspace = true } +bytes = { workspace = true } sha2 = { version = "0.10", optional = true } hex = { version = "0.4", optional = true } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 7705f1a..63c5f4a 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::pin::Pin; use crate::{ errors::DomainError, @@ -19,6 +20,17 @@ use crate::{ }, }; use async_trait::async_trait; +use bytes::Bytes; + +pub type DataStream = + Pin> + Send>>; + +#[async_trait] +pub trait MediaStore: Send + Sync { + async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>; + async fn get(&self, key: &str) -> Result; + async fn delete(&self, key: &str) -> Result<(), DomainError>; +} pub struct GeneratedToken { pub token: String, diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs index cb13e59..ac647e4 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -134,11 +134,21 @@ impl UserWriter for TestStore { .iter_mut() .find(|u| &u.id == user_id) { - u.display_name = input.display_name; - u.bio = input.bio; - u.avatar_url = input.avatar_url; - u.header_url = input.header_url; - u.custom_css = input.custom_css; + if let Some(v) = input.display_name { + u.display_name = Some(v); + } + if let Some(v) = input.bio { + u.bio = Some(v); + } + if let Some(v) = input.avatar_url { + u.avatar_url = Some(v); + } + if let Some(v) = input.header_url { + u.header_url = Some(v); + } + if let Some(v) = input.custom_css { + u.custom_css = Some(v); + } } Ok(()) } diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index fecb500..de07473 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -17,6 +17,7 @@ uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +futures = { workspace = true } url = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } diff --git a/crates/presentation/src/handlers/media.rs b/crates/presentation/src/handlers/media.rs new file mode 100644 index 0000000..6bf25f1 --- /dev/null +++ b/crates/presentation/src/handlers/media.rs @@ -0,0 +1,51 @@ +use crate::{ + errors::ApiError, + extractors::{Deps, FromAppState}, + state::AppState, +}; +use axum::{ + body::Body, + extract::Path, + http::header, + response::{IntoResponse, Response}, +}; +use domain::ports::MediaStore; +use futures::TryStreamExt; +use std::sync::Arc; + +pub struct MediaDeps { + pub media: Arc, +} + +impl FromAppState for MediaDeps { + fn from_state(s: &AppState) -> Self { + Self { + media: s.media.clone(), + } + } +} + +fn ext_to_mime(ext: &str) -> &'static str { + match ext { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} + +pub async fn get_media( + Deps(d): Deps, + Path(path): Path, +) -> Result { + let stream = d.media.get(&path).await?; + let content_type = path + .rsplit('.') + .next() + .map(ext_to_mime) + .unwrap_or("application/octet-stream"); + let body = Body::from_stream(stream.map_err(|e| e.to_string())); + Ok(([(header::CONTENT_TYPE, content_type)], body).into_response()) +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 44351c1..811e515 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod federation_actors; pub mod federation_management; pub mod feed; pub mod health; +pub mod media; pub mod notifications; pub mod social; pub mod thoughts; diff --git a/crates/presentation/src/handlers/users/mod.rs b/crates/presentation/src/handlers/users/mod.rs index e050e8b..a427604 100644 --- a/crates/presentation/src/handlers/users/mod.rs +++ b/crates/presentation/src/handlers/users/mod.rs @@ -10,16 +10,20 @@ use api_types::{ }; use application::use_cases::profile::{ get_user as fetch_user, get_user_by_id_or_username, update_profile, + upload_avatar as upload_avatar_uc, upload_banner as upload_banner_uc, UploadConfig, }; use axum::{ - extract::{Path, Query}, + extract::{Multipart, Path, Query}, http::{header, HeaderMap}, response::{IntoResponse, Response}, Json, }; use domain::{ models::user::UpdateProfileInput, - ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository}, + ports::{ + EventPublisher, FederationActionPort, FollowRepository, MediaStore, SearchPort, + UserRepository, + }, }; use std::sync::Arc; @@ -29,6 +33,9 @@ pub struct UsersDeps { pub follows: Arc, pub federation: Arc, pub search: Arc, + pub media: Arc, + pub upload_config: UploadConfig, + pub base_url: String, } impl FromAppState for UsersDeps { @@ -39,6 +46,9 @@ impl FromAppState for UsersDeps { follows: s.follows.clone(), federation: s.federation.clone(), search: s.search.clone(), + media: s.media.clone(), + upload_config: s.upload_config.clone(), + base_url: s.base_url.clone(), } } } @@ -88,6 +98,8 @@ pub async fn get_user( ), security(("bearer_auth" = [])) )] +// avatar_url and header_url in UpdateProfileRequest are accepted as-is (external +// URLs allowed). The upload use-cases handle storage-backed uploads separately. pub async fn patch_profile( Deps(d): Deps, AuthUser(uid): AuthUser, @@ -228,5 +240,77 @@ pub async fn lookup_handler( })) } +pub async fn upload_avatar( + Deps(d): Deps, + AuthUser(uid): AuthUser, + mut multipart: Multipart, +) -> Result, ApiError> { + let field = multipart + .next_field() + .await + .map_err(|_| ApiError::BadRequest("invalid multipart".into()))? + .ok_or_else(|| ApiError::BadRequest("no file field".into()))?; + // Content-type is client-supplied; the use-case allowlist prevents obviously + // wrong types, but magic-byte validation is not performed. Serve media files + // from an isolated origin to prevent MIME-based XSS. + let content_type = field + .content_type() + .ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))? + .to_string(); + let data = field + .bytes() + .await + .map_err(|_| ApiError::BadRequest("failed to read upload".into()))?; + upload_avatar_uc( + &*d.users, + &*d.media, + &*d.events, + &uid, + &d.base_url, + &d.upload_config, + &content_type, + data, + ) + .await?; + let user = fetch_user(&*d.users, &uid).await?; + Ok(Json(to_user_response(&user))) +} + +pub async fn upload_banner( + Deps(d): Deps, + AuthUser(uid): AuthUser, + mut multipart: Multipart, +) -> Result, ApiError> { + let field = multipart + .next_field() + .await + .map_err(|_| ApiError::BadRequest("invalid multipart".into()))? + .ok_or_else(|| ApiError::BadRequest("no file field".into()))?; + // Content-type is client-supplied; the use-case allowlist prevents obviously + // wrong types, but magic-byte validation is not performed. Serve media files + // from an isolated origin to prevent MIME-based XSS. + let content_type = field + .content_type() + .ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))? + .to_string(); + let data = field + .bytes() + .await + .map_err(|_| ApiError::BadRequest("failed to read upload".into()))?; + upload_banner_uc( + &*d.users, + &*d.media, + &*d.events, + &uid, + &d.base_url, + &d.upload_config, + &content_type, + data, + ) + .await?; + let user = fetch_user(&*d.users, &uid).await?; + Ok(Json(to_user_response(&user))) +} + #[cfg(test)] mod tests; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index d0025cc..1c83f78 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,5 +1,6 @@ use crate::{handlers::*, openapi, state::AppState}; use axum::{ + extract::DefaultBodyLimit, routing::{delete, get, patch, post, put}, Router, }; @@ -16,6 +17,14 @@ pub fn router() -> Router { .route("/users/count", get(users::get_user_count)) .route("/users/lookup", get(users::lookup_handler)) .route("/users/me", get(users::get_me).patch(users::patch_profile)) + .route( + "/users/me/avatar", + put(users::upload_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route( + "/users/me/banner", + put(users::upload_banner).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) .route("/users/me/following", get(users::get_me_following)) .route("/users/me/top-friends", put(social::put_top_friends)) .route("/users/{username}", get(users::get_user)) @@ -113,5 +122,5 @@ pub fn router() -> Router { ) .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)); - openapi::serve(api_routes) + openapi::serve(api_routes).route("/media/{*path}", get(media::get_media)) } diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 308baca..6dec547 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -1,4 +1,5 @@ use activitypub::ActivityPubRepository; +use application::use_cases::profile::UploadConfig; use domain::ports::*; use std::sync::Arc; @@ -27,4 +28,7 @@ pub struct AppState { pub remote_actor_connections: Arc, pub federation_scheduler: Arc, pub engagement: Arc, + pub media: Arc, + pub upload_config: UploadConfig, + pub base_url: String, } diff --git a/crates/presentation/src/testing.rs b/crates/presentation/src/testing.rs index 2e2d6b9..d2ec459 100644 --- a/crates/presentation/src/testing.rs +++ b/crates/presentation/src/testing.rs @@ -1,9 +1,10 @@ use crate::state::AppState; use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry}; +use application::use_cases::profile::UploadConfig; use async_trait::async_trait; use domain::{ errors::DomainError, - ports::{AuthService, GeneratedToken, PasswordHasher}, + ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher}, testing::{NoOpOutboxWriter, TestStore}, value_objects::{PasswordHash, ThoughtId, UserId}, }; @@ -98,6 +99,21 @@ impl ActivityPubRepository for NoOpApRepo { } } +pub struct NoOpMediaStore; + +#[async_trait] +impl MediaStore for NoOpMediaStore { + async fn put(&self, _key: &str, _data: DataStream) -> Result<(), DomainError> { + Err(DomainError::Internal("noop".into())) + } + async fn get(&self, _key: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn delete(&self, _key: &str) -> Result<(), DomainError> { + Err(DomainError::Internal("noop".into())) + } +} + pub fn make_state() -> AppState { let store = Arc::new(TestStore::default()); AppState { @@ -124,5 +140,8 @@ pub fn make_state() -> AppState { federation_scheduler: store.clone(), api_key_auth: store.clone(), engagement: store.clone(), + media: Arc::new(NoOpMediaStore), + upload_config: UploadConfig::default(), + base_url: "http://localhost:3000".into(), } } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 4f04741..4d87697 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -13,7 +13,7 @@ application = { workspace = true } nats = { workspace = true } event-transport = { workspace = true } event-payload = { workspace = true } -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" } activitypub = { workspace = true } postgres = { workspace = true } postgres-federation = { workspace = true } diff --git a/crates/worker/src/factory.rs b/crates/worker/src/factory.rs index 0604a04..597bb40 100644 --- a/crates/worker/src/factory.rs +++ b/crates/worker/src/factory.rs @@ -3,11 +3,11 @@ use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; use sqlx::PgPool; use std::sync::Arc; -use activitypub::{ApFederationAdapter, ThoughtsObjectHandler}; use activitypub::{ActivityPubRepository, OutboundFederationPort}; -use k_ap::ActivityPubService; +use activitypub::{ApFederationAdapter, ThoughtsObjectHandler}; use application::services::{FederationEventService, NotificationEventService}; use domain::ports::EventPublisher; +use k_ap::ActivityPubService; use postgres::activitypub::PgActivityPubRepository; use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; @@ -39,8 +39,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker )); // ActivityPub service (for federation fan-out) - let connections_repo_worker = - Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())); + let connections_repo_worker = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())); let raw_ap_service = Arc::new( ActivityPubService::builder( Arc::new(PostgresFederationRepository::new(pool.clone())), @@ -61,7 +60,10 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker .await .expect("ActivityPubService build failed"), ); - let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo_worker)); + let ap_service = Arc::new(ApFederationAdapter::new( + raw_ap_service, + connections_repo_worker, + )); let ap_outbound = ap_service.clone() as Arc; let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; diff --git a/thoughts-frontend/app/settings/profile/page.tsx b/thoughts-frontend/app/settings/profile/page.tsx index dbf77df..5510178 100644 --- a/thoughts-frontend/app/settings/profile/page.tsx +++ b/thoughts-frontend/app/settings/profile/page.tsx @@ -31,7 +31,7 @@ export default async function EditProfilePage() { This is how others will see you on the site.

- + ); } 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) {
+ + {/* Banner */} +
+

Banner

+
!uploadingBanner && bannerInputRef.current?.click()} + > + {bannerSrc ? ( + Banner + ) : ( +
+ No banner +
+ )} +
+ {uploadingBanner ? ( + + ) : ( + + )} +
+
+ +
+ + {/* Avatar */} +
+

Avatar

+
+
!uploadingAvatar && avatarInputRef.current?.click()} + > + +
+ {uploadingAvatar ? ( + + ) : ( + + )} +
+
+

+ Click to upload · JPEG, PNG, GIF, WebP, AVIF · max 5 MB +

+
+ +
+ )} /> - ( - - Avatar URL - - - - - - )} - /> - ( - - Header URL - - - - - - )} - /> export const updateProfile = (data: z.infer, token: string) => apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token); +async function uploadImage(endpoint: string, file: File, token: string): Promise { + const base = process.env.NEXT_PUBLIC_API_URL; + const body = new FormData(); + body.append("file", file); + const res = await fetch(`${base}${endpoint}`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}` }, + body, + }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return UserSchema.parse(await res.json()); +} + +export const uploadAvatar = (file: File, token: string) => + uploadImage("/users/me/avatar", file, token); + +export const uploadBanner = (file: File, token: string) => + uploadImage("/users/me/banner", file, token); + export const getMeFollowingList = (token: string) => apiFetch("/users/me/following", { next: { tags: ['me'] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);