feat: add image upload for avatar and banner
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
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};
|
||||
Reference in New Issue
Block a user