feat: add image upload for avatar and banner

This commit is contained in:
2026-05-24 02:06:47 +02:00
parent 1874954ad7
commit 01932cf337
40 changed files with 1396 additions and 112 deletions

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }

View File

@@ -10,9 +10,9 @@ use url::Url;
use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls;
use k_ap::ApObjectHandler;
use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
use k_ap::ApObjectHandler;
pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>,

View File

@@ -6,6 +6,8 @@ pub mod urls;
pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote;
pub use port::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
pub use port::{
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
};
pub use service::ApFederationAdapter;
pub use urls::ThoughtsUrls;

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc};
use k_ap::NoteType;
use k_ap::AS_PUBLIC;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;

View File

@@ -55,7 +55,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
-> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
@@ -100,7 +100,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>;
-> Result<Option<ActorApUrls>, DomainError>;
}
#[async_trait]

View File

@@ -146,12 +146,14 @@ async fn resolve_actor_profiles_from_urls(
let display_name = resp["name"].as_str().map(|s| s.to_string());
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
Some(domain::models::actor_connection_summary::ActorConnectionSummary {
url: ap_url,
handle,
display_name,
avatar_url,
})
Some(
domain::models::actor_connection_summary::ActorConnectionSummary {
url: ap_url,
handle,
display_name,
avatar_url,
},
)
}
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
@@ -254,7 +256,13 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_create_note(user_uuid, note)
.await
@@ -266,8 +274,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id = url::Url::parse(thought_ap_id)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let ap_id =
url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
.await
@@ -284,7 +292,13 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(thought, &ap_id, &followers_url, self.base_url(), in_reply_to_url);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_update_note(user_uuid, note)
.await
@@ -296,8 +310,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id = url::Url::parse(object_ap_id)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
@@ -309,8 +323,8 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id = url::Url::parse(object_ap_id)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
@@ -323,10 +337,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
@@ -339,10 +353,10 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
@@ -435,8 +449,7 @@ impl FederationSchedulerPort for ApFederationAdapter {
let empty = vec![];
let items = val["orderedItems"].as_array().unwrap_or(&empty);
for item in items {
let actor_url =
item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
if !actor_url.is_empty() {
all_urls.push(actor_url.to_string());
}
@@ -490,9 +503,9 @@ impl FederationSchedulerPort for ApFederationAdapter {
impl FederationLookupPort for ApFederationAdapter {
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
let normalized = handle.trim_start_matches('@');
let at = normalized.rfind('@').ok_or_else(|| {
DomainError::InvalidInput("handle must be user@domain".into())
})?;
let at = normalized
.rfind('@')
.ok_or_else(|| DomainError::InvalidInput("handle must be user@domain".into()))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
@@ -532,8 +545,10 @@ impl FederationLookupPort for ApFederationAdapter {
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
let preferred_username =
actor_json["preferredUsername"].as_str().unwrap_or("").to_string();
let preferred_username = actor_json["preferredUsername"]
.as_str()
.unwrap_or("")
.to_string();
let domain_part = url::Url::parse(&ap_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
@@ -645,10 +660,9 @@ impl FederationFetchPort for ApFederationAdapter {
return None;
}
let published =
DateTime::parse_from_rfc3339(note["published"].as_str()?)
.ok()?
.with_timezone(&chrono::Utc);
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
.ok()?
.with_timezone(&chrono::Utc);
let text = note["content"].as_str().unwrap_or("").to_string();
let has_attachments = note["attachment"]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -268,7 +268,14 @@ impl UserWriter for PgUserRepository {
input: UpdateProfileInput,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
"UPDATE users SET \
display_name = COALESCE($2, display_name), \
bio = COALESCE($3, bio), \
avatar_url = COALESCE($4, avatar_url), \
header_url = COALESCE($5, header_url), \
custom_css = COALESCE($6, custom_css), \
updated_at = NOW() \
WHERE id = $1",
)
.bind(user_id.as_uuid())
.bind(input.display_name)

View 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"] }

View 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());
}
}

View 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 { "" },
),
}
}

View File

@@ -0,0 +1,5 @@
pub mod adapter;
pub mod config;
pub use adapter::ObjectStorageAdapter;
pub use config::{build_store, StorageConfig};

View File

@@ -15,6 +15,8 @@ hex = "0.4"
tracing = { workspace = true }
url = { workspace = true }
tokio = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -1,5 +1,6 @@
const MAX_TOP_FRIENDS: usize = 8;
use bytes::Bytes;
use domain::{
errors::DomainError,
events::DomainEvent,
@@ -7,7 +8,9 @@ use domain::{
top_friend::TopFriend,
user::{UpdateProfileInput, User},
},
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
ports::{
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter,
},
value_objects::{UserId, Username},
};
@@ -81,5 +84,151 @@ pub async fn set_top_friends(
top_friends.set_top_friends(user_id, friends).await
}
#[derive(Clone)]
pub struct UploadConfig {
pub max_bytes: usize,
pub allowed_content_types: Vec<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)]
mod tests;

View File

@@ -5,6 +5,7 @@ use domain::{
testing::TestStore,
value_objects::{Email, PasswordHash, UserId, Username},
};
use std::sync::{Arc, Mutex};
fn make_user() -> User {
User::new_local(
@@ -64,3 +65,219 @@ async fn get_user_by_username_returns_correct_user() {
let found = get_user_by_username(&store, "alice").await.unwrap();
assert_eq!(found.id, user.id);
}
// ── upload tests ──────────────────────────────────────────────────────────────
use bytes::Bytes;
use domain::ports::{DataStream, MediaStore};
use std::collections::HashMap;
#[derive(Default, Clone)]
struct MockMedia {
store: Arc<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));
}

View File

@@ -14,10 +14,12 @@ postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { workspace = true }
activitypub = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
nats = { workspace = true }
event-transport = { workspace = true }
auth = { workspace = true }
storage = { workspace = true }
application = { workspace = true }
sqlx = { workspace = true }
async-nats = { workspace = true }
async-trait = { workspace = true }

View File

@@ -11,6 +11,18 @@ pub struct Config {
pub host: String,
pub cors_origins: String,
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 {
@@ -36,6 +48,23 @@ impl Config {
rate_limit: std::env::var("RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok()),
storage_backend: std::env::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".into()),
storage_path: std::env::var("STORAGE_PATH").ok(),
storage_prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(),
s3_endpoint: std::env::var("S3_ENDPOINT").ok(),
s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(),
s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(),
s3_bucket: std::env::var("S3_BUCKET").ok(),
s3_region: std::env::var("S3_REGION").ok(),
upload_max_bytes: std::env::var("UPLOAD_MAX_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5 * 1024 * 1024),
upload_allowed_types: std::env::var("UPLOAD_ALLOWED_TYPES")
.unwrap_or_else(|_| "image/jpeg,image/png,image/gif,image/webp,image/avif".into())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
}
}
}

View File

@@ -5,8 +5,10 @@ use async_trait::async_trait;
use sqlx::PgPool;
use std::sync::Arc;
use application::use_cases::profile::UploadConfig;
use storage::{build_store, ObjectStorageAdapter, StorageConfig};
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use k_ap::ActivityPubService;
use auth::ApiKeyServiceImpl;
use domain::{
errors::DomainError,
@@ -14,6 +16,7 @@ use domain::{
ports::{EventPublisher, OutboxWriter},
};
use event_transport::EventPublisherAdapter;
use k_ap::ActivityPubService;
use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository;
use postgres::engagement::PgEngagementRepository;
@@ -72,8 +75,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
};
// 3. ActivityPub federation
let connections_repo =
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let raw_ap_service = Arc::new(
ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())),
@@ -98,7 +100,27 @@ pub async fn build(cfg: &Config) -> Infrastructure {
);
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
// 4. Application state
// 4. Storage adapter
let storage_cfg = StorageConfig {
backend: cfg.storage_backend.clone(),
local_path: cfg.storage_path.clone(),
s3_endpoint: cfg.s3_endpoint.clone(),
s3_access_key_id: cfg.s3_access_key_id.clone(),
s3_secret_access_key: cfg.s3_secret_access_key.clone(),
s3_bucket: cfg.s3_bucket.clone(),
s3_region: cfg.s3_region.clone(),
};
let object_store = build_store(&storage_cfg).expect("Failed to build object store");
let media_adapter: Arc<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 {
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
@@ -140,6 +162,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
postgres::api_key::PgApiKeyRepository::new(pool.clone()),
))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
media: media_adapter,
upload_config,
base_url: cfg.base_url.clone(),
};
Infrastructure { state, ap_service }

View File

@@ -14,6 +14,7 @@ chrono = { workspace = true }
serde = { workspace = true }
futures = { workspace = true }
url = { workspace = true }
bytes = { workspace = true }
sha2 = { version = "0.10", optional = true }
hex = { version = "0.4", optional = true }

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::pin::Pin;
use crate::{
errors::DomainError,
@@ -19,6 +20,17 @@ use crate::{
},
};
use async_trait::async_trait;
use bytes::Bytes;
pub type DataStream =
Pin<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 token: String,

View File

@@ -134,11 +134,21 @@ impl UserWriter for TestStore {
.iter_mut()
.find(|u| &u.id == user_id)
{
u.display_name = input.display_name;
u.bio = input.bio;
u.avatar_url = input.avatar_url;
u.header_url = input.header_url;
u.custom_css = input.custom_css;
if let Some(v) = input.display_name {
u.display_name = Some(v);
}
if let Some(v) = input.bio {
u.bio = Some(v);
}
if let Some(v) = input.avatar_url {
u.avatar_url = Some(v);
}
if let Some(v) = input.header_url {
u.header_url = Some(v);
}
if let Some(v) = input.custom_css {
u.custom_css = Some(v);
}
}
Ok(())
}

View File

@@ -17,6 +17,7 @@ uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
url = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }

View 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())
}

View File

@@ -4,6 +4,7 @@ pub mod federation_actors;
pub mod federation_management;
pub mod feed;
pub mod health;
pub mod media;
pub mod notifications;
pub mod social;
pub mod thoughts;

View File

@@ -10,16 +10,20 @@ use api_types::{
};
use application::use_cases::profile::{
get_user as fetch_user, get_user_by_id_or_username, update_profile,
upload_avatar as upload_avatar_uc, upload_banner as upload_banner_uc, UploadConfig,
};
use axum::{
extract::{Path, Query},
extract::{Multipart, Path, Query},
http::{header, HeaderMap},
response::{IntoResponse, Response},
Json,
};
use domain::{
models::user::UpdateProfileInput,
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
ports::{
EventPublisher, FederationActionPort, FollowRepository, MediaStore, SearchPort,
UserRepository,
},
};
use std::sync::Arc;
@@ -29,6 +33,9 @@ pub struct UsersDeps {
pub follows: Arc<dyn FollowRepository>,
pub federation: Arc<dyn FederationActionPort>,
pub search: Arc<dyn SearchPort>,
pub media: Arc<dyn MediaStore>,
pub upload_config: UploadConfig,
pub base_url: String,
}
impl FromAppState for UsersDeps {
@@ -39,6 +46,9 @@ impl FromAppState for UsersDeps {
follows: s.follows.clone(),
federation: s.federation.clone(),
search: s.search.clone(),
media: s.media.clone(),
upload_config: s.upload_config.clone(),
base_url: s.base_url.clone(),
}
}
}
@@ -88,6 +98,8 @@ pub async fn get_user(
),
security(("bearer_auth" = []))
)]
// avatar_url and header_url in UpdateProfileRequest are accepted as-is (external
// URLs allowed). The upload use-cases handle storage-backed uploads separately.
pub async fn patch_profile(
Deps(d): Deps<UsersDeps>,
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)]
mod tests;

View File

@@ -1,5 +1,6 @@
use crate::{handlers::*, openapi, state::AppState};
use axum::{
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put},
Router,
};
@@ -16,6 +17,14 @@ pub fn router() -> Router<AppState> {
.route("/users/count", get(users::get_user_count))
.route("/users/lookup", get(users::lookup_handler))
.route("/users/me", get(users::get_me).patch(users::patch_profile))
.route(
"/users/me/avatar",
put(users::upload_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
.route(
"/users/me/banner",
put(users::upload_banner).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
.route("/users/me/following", get(users::get_me_following))
.route("/users/me/top-friends", put(social::put_top_friends))
.route("/users/{username}", get(users::get_user))
@@ -113,5 +122,5 @@ pub fn router() -> Router<AppState> {
)
.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))
}

View File

@@ -1,4 +1,5 @@
use activitypub::ActivityPubRepository;
use application::use_cases::profile::UploadConfig;
use domain::ports::*;
use std::sync::Arc;
@@ -27,4 +28,7 @@ pub struct AppState {
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
pub engagement: Arc<dyn EngagementRepository>,
pub media: Arc<dyn MediaStore>,
pub upload_config: UploadConfig,
pub base_url: String,
}

View File

@@ -1,9 +1,10 @@
use crate::state::AppState;
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use application::use_cases::profile::UploadConfig;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher},
testing::{NoOpOutboxWriter, TestStore},
value_objects::{PasswordHash, ThoughtId, UserId},
};
@@ -98,6 +99,21 @@ impl ActivityPubRepository for NoOpApRepo {
}
}
pub struct NoOpMediaStore;
#[async_trait]
impl MediaStore for NoOpMediaStore {
async fn put(&self, _key: &str, _data: DataStream) -> Result<(), DomainError> {
Err(DomainError::Internal("noop".into()))
}
async fn get(&self, _key: &str) -> Result<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 {
let store = Arc::new(TestStore::default());
AppState {
@@ -124,5 +140,8 @@ pub fn make_state() -> AppState {
federation_scheduler: store.clone(),
api_key_auth: store.clone(),
engagement: store.clone(),
media: Arc::new(NoOpMediaStore),
upload_config: UploadConfig::default(),
base_url: "http://localhost:3000".into(),
}
}

View File

@@ -13,7 +13,7 @@ application = { workspace = true }
nats = { workspace = true }
event-transport = { workspace = true }
event-payload = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.2" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
activitypub = { workspace = true }
postgres = { workspace = true }
postgres-federation = { workspace = true }

View File

@@ -3,11 +3,11 @@ use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
use sqlx::PgPool;
use std::sync::Arc;
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use activitypub::{ActivityPubRepository, OutboundFederationPort};
use k_ap::ActivityPubService;
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use application::services::{FederationEventService, NotificationEventService};
use domain::ports::EventPublisher;
use k_ap::ActivityPubService;
use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
@@ -39,8 +39,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
));
// ActivityPub service (for federation fan-out)
let connections_repo_worker =
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let connections_repo_worker = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let raw_ap_service = Arc::new(
ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())),
@@ -61,7 +60,10 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
.await
.expect("ActivityPubService build failed"),
);
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo_worker));
let ap_service = Arc::new(ApFederationAdapter::new(
raw_ap_service,
connections_repo_worker,
));
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
let ap_repo_worker =
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;