feat: add image upload for avatar and banner
This commit is contained in:
16
.env.example
16
.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)
|
# but events will not be delivered to the worker)
|
||||||
# NATS_URL=nats://localhost:4222
|
# 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
|
# Logging
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
/docs/superpowers/
|
||||||
|
|
||||||
|
/media
|
||||||
185
Cargo.lock
generated
185
Cargo.lock
generated
@@ -14,7 +14,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"reqwest",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -47,12 +47,12 @@ dependencies = [
|
|||||||
"http-signature-normalization",
|
"http-signature-normalization",
|
||||||
"http-signature-normalization-reqwest",
|
"http-signature-normalization-reqwest",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"moka",
|
"moka",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest 0.13.3",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -275,8 +275,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -469,6 +471,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -580,6 +583,7 @@ name = "bootstrap"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
|
"application",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auth",
|
"auth",
|
||||||
@@ -595,6 +599,7 @@ dependencies = [
|
|||||||
"postgres-search",
|
"postgres-search",
|
||||||
"presentation",
|
"presentation",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"storage",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower_governor",
|
"tower_governor",
|
||||||
@@ -1040,6 +1045,7 @@ name = "domain"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -1623,7 +1629,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"http-signature-normalization",
|
"http-signature-normalization",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"reqwest",
|
"reqwest 0.13.3",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1641,6 +1647,12 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -1673,6 +1685,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1888,6 +1901,15 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1992,7 +2014,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2001,7 +2023,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"enum_delegate",
|
"enum_delegate",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2187,6 +2209,23 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "nats"
|
name = "nats"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2311,6 +2350,36 @@ dependencies = [
|
|||||||
"libm",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -2534,6 +2603,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
|
"futures",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2582,6 +2652,16 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2797,6 +2877,48 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
@@ -2836,7 +2958,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2849,7 +2971,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"reqwest",
|
"reqwest 0.13.3",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -3289,6 +3411,27 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.5.10"
|
version = "0.5.10"
|
||||||
@@ -3541,6 +3684,19 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"domain",
|
||||||
|
"futures",
|
||||||
|
"object_store",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -4290,6 +4446,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
"crates/adapters/nats",
|
"crates/adapters/nats",
|
||||||
"crates/adapters/event-payload",
|
"crates/adapters/event-payload",
|
||||||
"crates/adapters/event-transport",
|
"crates/adapters/event-transport",
|
||||||
|
"crates/adapters/storage",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ async-trait = "0.1"
|
|||||||
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
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"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
bytes = "1.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
async-nats = "0.48"
|
async-nats = "0.48"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
@@ -50,3 +52,4 @@ auth = { path = "crates/adapters/auth" }
|
|||||||
nats = { path = "crates/adapters/nats" }
|
nats = { path = "crates/adapters/nats" }
|
||||||
event-payload = { path = "crates/adapters/event-payload" }
|
event-payload = { path = "crates/adapters/event-payload" }
|
||||||
event-transport = { path = "crates/adapters/event-transport" }
|
event-transport = { path = "crates/adapters/event-transport" }
|
||||||
|
storage = { path = "crates/adapters/storage" }
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ WORKDIR /build
|
|||||||
# Cache dependency compilation separately from source
|
# Cache dependency compilation separately from source
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
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/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-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/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/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
|
# Now copy real source and build
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
|
|
||||||
RUN cargo build --release -p bootstrap -p worker
|
RUN cargo build --release -p bootstrap -p worker --features storage/s3
|
||||||
|
|
||||||
# ----- runtime -----
|
# ----- runtime -----
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -63,6 +63,7 @@ bootstrap — binary: thoughts (API server)
|
|||||||
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
|
||||||
adapters/
|
adapters/
|
||||||
auth — JWT issuance and validation, Argon2 password hashing
|
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 — PostgreSQL repositories for all domain entities
|
||||||
postgres-search — PostgreSQL trigram full-text search
|
postgres-search — PostgreSQL trigram full-text search
|
||||||
postgres-federation — PostgreSQL-backed federation repository
|
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.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Rust stable (1.80+)
|
- 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 |
|
| `ALLOW_REGISTRATION` | `true` | Set to `false` to close sign-ups |
|
||||||
| `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging |
|
| `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging |
|
||||||
| `RUST_LOG` | `info` | Log level filter (`error`, `warn`, `info`, `debug`, `trace`) |
|
| `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
|
## Run
|
||||||
|
|
||||||
@@ -167,6 +182,9 @@ docker run -p 8000:8000 \
|
|||||||
-e JWT_SECRET=change-me \
|
-e JWT_SECRET=change-me \
|
||||||
-e BASE_URL=https://yourdomain.example.com \
|
-e BASE_URL=https://yourdomain.example.com \
|
||||||
-e NATS_URL=nats://nats:4222 \
|
-e NATS_URL=nats://nats:4222 \
|
||||||
|
-e STORAGE_BACKEND=local \
|
||||||
|
-e STORAGE_PATH=/data/media \
|
||||||
|
-v media_vol:/data/media \
|
||||||
thoughts
|
thoughts
|
||||||
|
|
||||||
# Event worker (same image, different entrypoint)
|
# Event worker (same image, different entrypoint)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ services:
|
|||||||
BASE_URL: http://localhost:8000
|
BASE_URL: http://localhost:8000
|
||||||
NATS_URL: nats://nats:4222
|
NATS_URL: nats://nats:4222
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
|
STORAGE_BACKEND: local
|
||||||
|
STORAGE_PATH: /data/media
|
||||||
|
volumes:
|
||||||
|
- media_data:/data/media
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -65,3 +69,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
media_data:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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 }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use url::Url;
|
|||||||
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||||
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||||
use crate::urls::ThoughtsUrls;
|
use crate::urls::ThoughtsUrls;
|
||||||
use k_ap::ApObjectHandler;
|
|
||||||
use domain::ports::{EventPublisher, TagRepository};
|
use domain::ports::{EventPublisher, TagRepository};
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
|
use k_ap::ApObjectHandler;
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
repo: Arc<dyn ActivityPubRepository>,
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ pub mod urls;
|
|||||||
|
|
||||||
pub use handler::ThoughtsObjectHandler;
|
pub use handler::ThoughtsObjectHandler;
|
||||||
pub use note::ThoughtNote;
|
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 service::ApFederationAdapter;
|
||||||
pub use urls::ThoughtsUrls;
|
pub use urls::ThoughtsUrls;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
use k_ap::NoteType;
|
use k_ap::NoteType;
|
||||||
use k_ap::AS_PUBLIC;
|
use k_ap::AS_PUBLIC;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
|||||||
@@ -146,12 +146,14 @@ async fn resolve_actor_profiles_from_urls(
|
|||||||
let display_name = resp["name"].as_str().map(|s| s.to_string());
|
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());
|
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
Some(domain::models::actor_connection_summary::ActorConnectionSummary {
|
Some(
|
||||||
|
domain::models::actor_connection_summary::ActorConnectionSummary {
|
||||||
url: ap_url,
|
url: ap_url,
|
||||||
handle,
|
handle,
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
|
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 user_uuid = author_user_id.as_uuid();
|
||||||
let ap_id = self.actor_ap_id(user_uuid);
|
let ap_id = self.actor_ap_id(user_uuid);
|
||||||
let followers_url = self.actor_followers_url(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
|
self.inner
|
||||||
.broadcast_create_note(user_uuid, note)
|
.broadcast_create_note(user_uuid, note)
|
||||||
.await
|
.await
|
||||||
@@ -266,8 +274,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
author_user_id: &UserId,
|
author_user_id: &UserId,
|
||||||
thought_ap_id: &str,
|
thought_ap_id: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let ap_id = url::Url::parse(thought_ap_id)
|
let ap_id =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
|
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
|
||||||
.await
|
.await
|
||||||
@@ -284,7 +292,13 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
let user_uuid = author_user_id.as_uuid();
|
let user_uuid = author_user_id.as_uuid();
|
||||||
let ap_id = self.actor_ap_id(user_uuid);
|
let ap_id = self.actor_ap_id(user_uuid);
|
||||||
let followers_url = self.actor_followers_url(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
|
self.inner
|
||||||
.broadcast_update_note(user_uuid, note)
|
.broadcast_update_note(user_uuid, note)
|
||||||
.await
|
.await
|
||||||
@@ -296,8 +310,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
booster_user_id: &UserId,
|
booster_user_id: &UserId,
|
||||||
object_ap_id: &str,
|
object_ap_id: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let ap_id = url::Url::parse(object_ap_id)
|
let ap_id =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||||
.await
|
.await
|
||||||
@@ -309,8 +323,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
booster_user_id: &UserId,
|
booster_user_id: &UserId,
|
||||||
object_ap_id: &str,
|
object_ap_id: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let ap_id = url::Url::parse(object_ap_id)
|
let ap_id =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
|
||||||
.await
|
.await
|
||||||
@@ -323,10 +337,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
object_ap_id: &str,
|
object_ap_id: &str,
|
||||||
author_inbox_url: &str,
|
author_inbox_url: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let object = url::Url::parse(object_ap_id)
|
let object =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
let inbox = url::Url::parse(author_inbox_url)
|
let inbox =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||||
.await
|
.await
|
||||||
@@ -339,10 +353,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
object_ap_id: &str,
|
object_ap_id: &str,
|
||||||
author_inbox_url: &str,
|
author_inbox_url: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let object = url::Url::parse(object_ap_id)
|
let object =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
let inbox = url::Url::parse(author_inbox_url)
|
let inbox =
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
|
||||||
.await
|
.await
|
||||||
@@ -435,8 +449,7 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
let empty = vec![];
|
let empty = vec![];
|
||||||
let items = val["orderedItems"].as_array().unwrap_or(&empty);
|
let items = val["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
for item in items {
|
for item in items {
|
||||||
let actor_url =
|
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
|
||||||
item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
|
|
||||||
if !actor_url.is_empty() {
|
if !actor_url.is_empty() {
|
||||||
all_urls.push(actor_url.to_string());
|
all_urls.push(actor_url.to_string());
|
||||||
}
|
}
|
||||||
@@ -490,9 +503,9 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
impl FederationLookupPort for ApFederationAdapter {
|
impl FederationLookupPort for ApFederationAdapter {
|
||||||
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
||||||
let normalized = handle.trim_start_matches('@');
|
let normalized = handle.trim_start_matches('@');
|
||||||
let at = normalized.rfind('@').ok_or_else(|| {
|
let at = normalized
|
||||||
DomainError::InvalidInput("handle must be user@domain".into())
|
.rfind('@')
|
||||||
})?;
|
.ok_or_else(|| DomainError::InvalidInput("handle must be user@domain".into()))?;
|
||||||
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
||||||
|
|
||||||
let wf_url = format!(
|
let wf_url = format!(
|
||||||
@@ -532,8 +545,10 @@ impl FederationLookupPort for ApFederationAdapter {
|
|||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
|
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
|
||||||
let preferred_username =
|
let preferred_username = actor_json["preferredUsername"]
|
||||||
actor_json["preferredUsername"].as_str().unwrap_or("").to_string();
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
let domain_part = url::Url::parse(&ap_url)
|
let domain_part = url::Url::parse(&ap_url)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||||
@@ -645,8 +660,7 @@ impl FederationFetchPort for ApFederationAdapter {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let published =
|
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
||||||
DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_timezone(&chrono::Utc);
|
.with_timezone(&chrono::Utc);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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 }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -268,7 +268,14 @@ impl UserWriter for PgUserRepository {
|
|||||||
input: UpdateProfileInput,
|
input: UpdateProfileInput,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
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(user_id.as_uuid())
|
||||||
.bind(input.display_name)
|
.bind(input.display_name)
|
||||||
|
|||||||
18
crates/adapters/storage/Cargo.toml
Normal file
18
crates/adapters/storage/Cargo.toml
Normal file
@@ -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"] }
|
||||||
237
crates/adapters/storage/src/adapter.rs
Normal file
237
crates/adapters/storage/src/adapter.rs
Normal file
@@ -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<dyn ObjectStore>,
|
||||||
|
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<dyn ObjectStore>,
|
||||||
|
prefix: impl Into<String>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
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<DataStream, DomainError> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/adapters/storage/src/config.rs
Normal file
67
crates/adapters/storage/src/config.rs
Normal file
@@ -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<String>,
|
||||||
|
pub s3_endpoint: Option<String>,
|
||||||
|
pub s3_access_key_id: Option<String>,
|
||||||
|
pub s3_secret_access_key: Option<String>,
|
||||||
|
pub s3_bucket: Option<String>,
|
||||||
|
pub s3_region: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_store(config: &StorageConfig) -> Result<Arc<dyn ObjectStore>> {
|
||||||
|
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 { "" },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/adapters/storage/src/lib.rs
Normal file
5
crates/adapters/storage/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod adapter;
|
||||||
|
pub mod config;
|
||||||
|
|
||||||
|
pub use adapter::ObjectStorageAdapter;
|
||||||
|
pub use config::{build_store, StorageConfig};
|
||||||
@@ -15,6 +15,8 @@ hex = "0.4"
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const MAX_TOP_FRIENDS: usize = 8;
|
const MAX_TOP_FRIENDS: usize = 8;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
@@ -7,7 +8,9 @@ use domain::{
|
|||||||
top_friend::TopFriend,
|
top_friend::TopFriend,
|
||||||
user::{UpdateProfileInput, User},
|
user::{UpdateProfileInput, User},
|
||||||
},
|
},
|
||||||
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
|
ports::{
|
||||||
|
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter,
|
||||||
|
},
|
||||||
value_objects::{UserId, Username},
|
value_objects::{UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,5 +84,151 @@ pub async fn set_top_friends(
|
|||||||
top_friends.set_top_friends(user_id, friends).await
|
top_friends.set_top_friends(user_id, friends).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UploadConfig {
|
||||||
|
pub max_bytes: usize,
|
||||||
|
pub allowed_content_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, DomainError> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use domain::{
|
|||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
value_objects::{Email, PasswordHash, UserId, Username},
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
};
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn make_user() -> User {
|
fn make_user() -> User {
|
||||||
User::new_local(
|
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();
|
let found = get_user_by_username(&store, "alice").await.unwrap();
|
||||||
assert_eq!(found.id, user.id);
|
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<Mutex<HashMap<String, Bytes>>>,
|
||||||
|
deleted: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DataStream, DomainError> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ postgres = { workspace = true }
|
|||||||
postgres-search = { workspace = true }
|
postgres-search = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
activitypub = { 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 }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
|
storage = { workspace = true }
|
||||||
|
application = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
async-nats = { workspace = true }
|
async-nats = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ pub struct Config {
|
|||||||
pub host: String,
|
pub host: String,
|
||||||
pub cors_origins: String,
|
pub cors_origins: String,
|
||||||
pub rate_limit: Option<u32>,
|
pub rate_limit: Option<u32>,
|
||||||
|
// Storage
|
||||||
|
pub storage_backend: String,
|
||||||
|
pub storage_path: Option<String>,
|
||||||
|
pub storage_prefix: String,
|
||||||
|
pub s3_endpoint: Option<String>,
|
||||||
|
pub s3_access_key_id: Option<String>,
|
||||||
|
pub s3_secret_access_key: Option<String>,
|
||||||
|
pub s3_bucket: Option<String>,
|
||||||
|
pub s3_region: Option<String>,
|
||||||
|
// Upload limits
|
||||||
|
pub upload_max_bytes: usize,
|
||||||
|
pub upload_allowed_types: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -36,6 +48,23 @@ impl Config {
|
|||||||
rate_limit: std::env::var("RATE_LIMIT")
|
rate_limit: std::env::var("RATE_LIMIT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ use async_trait::async_trait;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::use_cases::profile::UploadConfig;
|
||||||
|
use storage::{build_store, ObjectStorageAdapter, StorageConfig};
|
||||||
|
|
||||||
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
||||||
use k_ap::ActivityPubService;
|
|
||||||
use auth::ApiKeyServiceImpl;
|
use auth::ApiKeyServiceImpl;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -14,6 +16,7 @@ use domain::{
|
|||||||
ports::{EventPublisher, OutboxWriter},
|
ports::{EventPublisher, OutboxWriter},
|
||||||
};
|
};
|
||||||
use event_transport::EventPublisherAdapter;
|
use event_transport::EventPublisherAdapter;
|
||||||
|
use k_ap::ActivityPubService;
|
||||||
use nats::NatsTransport;
|
use nats::NatsTransport;
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres::engagement::PgEngagementRepository;
|
use postgres::engagement::PgEngagementRepository;
|
||||||
@@ -72,8 +75,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. ActivityPub federation
|
// 3. ActivityPub federation
|
||||||
let connections_repo =
|
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||||
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
|
||||||
let raw_ap_service = Arc::new(
|
let raw_ap_service = Arc::new(
|
||||||
ActivityPubService::builder(
|
ActivityPubService::builder(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
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));
|
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<dyn domain::ports::MediaStore> = 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 {
|
let state = AppState {
|
||||||
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
||||||
thoughts: Arc::new(postgres::thought::PgThoughtRepository::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()),
|
postgres::api_key::PgApiKeyRepository::new(pool.clone()),
|
||||||
))),
|
))),
|
||||||
engagement: Arc::new(PgEngagementRepository::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 }
|
Infrastructure { state, ap_service }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
sha2 = { version = "0.10", optional = true }
|
sha2 = { version = "0.10", optional = true }
|
||||||
hex = { version = "0.4", optional = true }
|
hex = { version = "0.4", optional = true }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -19,6 +20,17 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
pub type DataStream =
|
||||||
|
Pin<Box<dyn futures::stream::Stream<Item = Result<Bytes, DomainError>> + 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<DataStream, DomainError>;
|
||||||
|
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GeneratedToken {
|
pub struct GeneratedToken {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
|||||||
@@ -134,11 +134,21 @@ impl UserWriter for TestStore {
|
|||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|u| &u.id == user_id)
|
.find(|u| &u.id == user_id)
|
||||||
{
|
{
|
||||||
u.display_name = input.display_name;
|
if let Some(v) = input.display_name {
|
||||||
u.bio = input.bio;
|
u.display_name = Some(v);
|
||||||
u.avatar_url = input.avatar_url;
|
}
|
||||||
u.header_url = input.header_url;
|
if let Some(v) = input.bio {
|
||||||
u.custom_css = input.custom_css;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ uuid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||||
|
|||||||
51
crates/presentation/src/handlers/media.rs
Normal file
51
crates/presentation/src/handlers/media.rs
Normal file
@@ -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<dyn MediaStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MediaDeps>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod federation_actors;
|
|||||||
pub mod federation_management;
|
pub mod federation_management;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod media;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod thoughts;
|
pub mod thoughts;
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ use api_types::{
|
|||||||
};
|
};
|
||||||
use application::use_cases::profile::{
|
use application::use_cases::profile::{
|
||||||
get_user as fetch_user, get_user_by_id_or_username, update_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::{
|
use axum::{
|
||||||
extract::{Path, Query},
|
extract::{Multipart, Path, Query},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::user::UpdateProfileInput,
|
models::user::UpdateProfileInput,
|
||||||
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
|
ports::{
|
||||||
|
EventPublisher, FederationActionPort, FollowRepository, MediaStore, SearchPort,
|
||||||
|
UserRepository,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -29,6 +33,9 @@ pub struct UsersDeps {
|
|||||||
pub follows: Arc<dyn FollowRepository>,
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
pub federation: Arc<dyn FederationActionPort>,
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
pub search: Arc<dyn SearchPort>,
|
pub search: Arc<dyn SearchPort>,
|
||||||
|
pub media: Arc<dyn MediaStore>,
|
||||||
|
pub upload_config: UploadConfig,
|
||||||
|
pub base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromAppState for UsersDeps {
|
impl FromAppState for UsersDeps {
|
||||||
@@ -39,6 +46,9 @@ impl FromAppState for UsersDeps {
|
|||||||
follows: s.follows.clone(),
|
follows: s.follows.clone(),
|
||||||
federation: s.federation.clone(),
|
federation: s.federation.clone(),
|
||||||
search: s.search.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" = []))
|
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(
|
pub async fn patch_profile(
|
||||||
Deps(d): Deps<UsersDeps>,
|
Deps(d): Deps<UsersDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -228,5 +240,77 @@ pub async fn lookup_handler(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upload_avatar(
|
||||||
|
Deps(d): Deps<UsersDeps>,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<UserResponse>, 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<UsersDeps>,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<UserResponse>, 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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::{handlers::*, openapi, state::AppState};
|
use crate::{handlers::*, openapi, state::AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
routing::{delete, get, patch, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -16,6 +17,14 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/users/count", get(users::get_user_count))
|
.route("/users/count", get(users::get_user_count))
|
||||||
.route("/users/lookup", get(users::lookup_handler))
|
.route("/users/lookup", get(users::lookup_handler))
|
||||||
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
.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/following", get(users::get_me_following))
|
||||||
.route("/users/me/top-friends", put(social::put_top_friends))
|
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||||
.route("/users/{username}", get(users::get_user))
|
.route("/users/{username}", get(users::get_user))
|
||||||
@@ -113,5 +122,5 @@ pub fn router() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
.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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use activitypub::ActivityPubRepository;
|
use activitypub::ActivityPubRepository;
|
||||||
|
use application::use_cases::profile::UploadConfig;
|
||||||
use domain::ports::*;
|
use domain::ports::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -27,4 +28,7 @@ pub struct AppState {
|
|||||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||||
pub engagement: Arc<dyn EngagementRepository>,
|
pub engagement: Arc<dyn EngagementRepository>,
|
||||||
|
pub media: Arc<dyn MediaStore>,
|
||||||
|
pub upload_config: UploadConfig,
|
||||||
|
pub base_url: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
|
use application::use_cases::profile::UploadConfig;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher},
|
||||||
testing::{NoOpOutboxWriter, TestStore},
|
testing::{NoOpOutboxWriter, TestStore},
|
||||||
value_objects::{PasswordHash, ThoughtId, UserId},
|
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<DataStream, DomainError> {
|
||||||
|
Err(DomainError::Internal("noop".into()))
|
||||||
|
}
|
||||||
|
async fn delete(&self, _key: &str) -> Result<(), DomainError> {
|
||||||
|
Err(DomainError::Internal("noop".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn make_state() -> AppState {
|
pub fn make_state() -> AppState {
|
||||||
let store = Arc::new(TestStore::default());
|
let store = Arc::new(TestStore::default());
|
||||||
AppState {
|
AppState {
|
||||||
@@ -124,5 +140,8 @@ pub fn make_state() -> AppState {
|
|||||||
federation_scheduler: store.clone(),
|
federation_scheduler: store.clone(),
|
||||||
api_key_auth: store.clone(),
|
api_key_auth: store.clone(),
|
||||||
engagement: store.clone(),
|
engagement: store.clone(),
|
||||||
|
media: Arc::new(NoOpMediaStore),
|
||||||
|
upload_config: UploadConfig::default(),
|
||||||
|
base_url: "http://localhost:3000".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ application = { workspace = true }
|
|||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
event-payload = { 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 }
|
activitypub = { workspace = true }
|
||||||
postgres = { workspace = true }
|
postgres = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
|
||||||
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
||||||
use k_ap::ActivityPubService;
|
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
||||||
use application::services::{FederationEventService, NotificationEventService};
|
use application::services::{FederationEventService, NotificationEventService};
|
||||||
use domain::ports::EventPublisher;
|
use domain::ports::EventPublisher;
|
||||||
|
use k_ap::ActivityPubService;
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
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)
|
// ActivityPub service (for federation fan-out)
|
||||||
let connections_repo_worker =
|
let connections_repo_worker = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||||
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
|
||||||
let raw_ap_service = Arc::new(
|
let raw_ap_service = Arc::new(
|
||||||
ActivityPubService::builder(
|
ActivityPubService::builder(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
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
|
.await
|
||||||
.expect("ActivityPubService build failed"),
|
.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<dyn OutboundFederationPort>;
|
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||||
let ap_repo_worker =
|
let ap_repo_worker =
|
||||||
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function EditProfilePage() {
|
|||||||
This is how others will see you on the site.
|
This is how others will see you on the site.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EditProfileForm currentUser={me} />
|
<EditProfileForm currentUser={me} token={token} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "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 { updateProfile } from "@/app/actions/profile";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,20 +20,63 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { Camera, ImagePlus, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface EditProfileFormProps {
|
interface EditProfileFormProps {
|
||||||
currentUser: Me;
|
currentUser: Me;
|
||||||
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
export function EditProfileForm({ currentUser, token }: EditProfileFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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<z.infer<typeof UpdateProfileSchema>>({
|
const form = useForm<z.infer<typeof UpdateProfileSchema>>({
|
||||||
resolver: zodResolver(UpdateProfileSchema),
|
resolver: zodResolver(UpdateProfileSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
displayName: currentUser.displayName ?? undefined,
|
displayName: currentUser.displayName ?? undefined,
|
||||||
bio: currentUser.bio ?? undefined,
|
bio: currentUser.bio ?? undefined,
|
||||||
avatarUrl: currentUser.avatarUrl ?? undefined,
|
|
||||||
headerUrl: currentUser.headerUrl ?? undefined,
|
|
||||||
customCss: currentUser.customCss ?? undefined,
|
customCss: currentUser.customCss ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,6 +96,78 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-6 pt-6">
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
|
||||||
|
{/* Banner */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Banner</p>
|
||||||
|
<div
|
||||||
|
className="relative h-32 rounded-md bg-muted overflow-hidden cursor-pointer group"
|
||||||
|
onClick={() => !uploadingBanner && bannerInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{bannerSrc ? (
|
||||||
|
<img
|
||||||
|
src={bannerSrc}
|
||||||
|
alt="Banner"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
No banner
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
{uploadingBanner ? (
|
||||||
|
<Loader2 className="text-white h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImagePlus className="text-white h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={bannerInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleBannerChange}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Avatar</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="relative w-20 h-20 rounded-full cursor-pointer group shrink-0"
|
||||||
|
onClick={() => !uploadingAvatar && avatarInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={currentUser.displayName}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
{uploadingAvatar ? (
|
||||||
|
<Loader2 className="text-white h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Camera className="text-white h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click to upload · JPEG, PNG, GIF, WebP, AVIF · max 5 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="displayName"
|
name="displayName"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -77,38 +194,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
name="avatarUrl"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Avatar URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/avatar.png"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
name="headerUrl"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Header URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/header.jpg"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="customCss"
|
name="customCss"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ export const CreateThoughtSchema = z.object({
|
|||||||
export const UpdateProfileSchema = z.object({
|
export const UpdateProfileSchema = z.object({
|
||||||
displayName: z.string().max(50).optional(),
|
displayName: z.string().max(50).optional(),
|
||||||
bio: z.string().max(4000).optional(),
|
bio: z.string().max(4000).optional(),
|
||||||
avatarUrl: z.string().optional(),
|
|
||||||
headerUrl: z.string().optional(),
|
|
||||||
customCss: z.string().optional(),
|
customCss: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,6 +212,25 @@ export const getMe = (token: string) =>
|
|||||||
export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token: string) =>
|
export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token: string) =>
|
||||||
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
|
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
|
||||||
|
|
||||||
|
async function uploadImage(endpoint: string, file: File, token: string): Promise<User> {
|
||||||
|
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) =>
|
export const getMeFollowingList = (token: string) =>
|
||||||
apiFetch("/users/me/following", { next: { tags: ['me'] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
apiFetch("/users/me/following", { next: { tags: ['me'] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user