feat: enhance media management with EXIF data extraction, metadata filtering, and storage path generation
refactor: update configuration handling to use environment variables and improve code organization
This commit is contained in:
@@ -1 +0,0 @@
|
||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/libertas_db"
|
||||
@@ -1,25 +1 @@
|
||||
use libertas_core::{
|
||||
config::{Config, DatabaseConfig, DatabaseType},
|
||||
error::CoreResult,
|
||||
};
|
||||
|
||||
pub fn load_config() -> CoreResult<Config> {
|
||||
Ok(Config {
|
||||
database: DatabaseConfig {
|
||||
db_type: DatabaseType::Postgres,
|
||||
url: "postgres://libertas:libertas_password@localhost:5436/libertas_db".to_string(),
|
||||
},
|
||||
server_address: "127.0.0.1:8080".to_string(),
|
||||
jwt_secret: "super_secret_jwt_key".to_string(),
|
||||
media_library_path: "media_library".to_string(),
|
||||
broker_url: "nats://localhost:4222".to_string(),
|
||||
max_upload_size_mb: Some(100),
|
||||
default_storage_quota_gb: Some(10),
|
||||
allowed_sort_columns: Some(vec![
|
||||
"date_taken".to_string(),
|
||||
"created_at".to_string(),
|
||||
"original_filename".to_string(),
|
||||
]),
|
||||
thumbnail_config: None,
|
||||
})
|
||||
}
|
||||
pub use libertas_core::config::load_config;
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{extract::{FromRequestParts, Query}, http::request::Parts};
|
||||
use libertas_core::{error::CoreError, schema::{FilterParams, ListMediaOptions, SortOrder, SortParams}};
|
||||
use libertas_core::{error::CoreError, schema::{FilterParams, ListMediaOptions, MetadataFilter, SortOrder, SortParams}};
|
||||
|
||||
use crate::{error::ApiError, schema::ListMediaParams, state::AppState};
|
||||
|
||||
@@ -18,8 +18,25 @@ impl From<ListMediaParams> for ListMediaOptions {
|
||||
}
|
||||
});
|
||||
|
||||
let metadata_filters = if params.metadata.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
params.metadata
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
s.split_once(":").map(|(key, value)| MetadataFilter {
|
||||
tag_name: key.to_string(),
|
||||
tag_value: value.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
};
|
||||
|
||||
let filter = Some(FilterParams {
|
||||
// e.g., mime_type: params.mime_type
|
||||
mime_type: params.mime_type,
|
||||
metadata_filters,
|
||||
});
|
||||
|
||||
ListMediaOptions { sort, filter }
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use libertas_core::{
|
||||
config::Config,
|
||||
config::{AppConfig},
|
||||
error::{CoreError, CoreResult},
|
||||
};
|
||||
use libertas_infra::factory::{
|
||||
build_album_repository, build_album_share_repository, build_database_pool,
|
||||
build_media_repository, build_user_repository,
|
||||
build_album_repository, build_album_share_repository, build_database_pool, build_media_metadata_repository, build_media_repository, build_user_repository
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -18,7 +17,7 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn build_app_state(config: Config) -> CoreResult<AppState> {
|
||||
pub async fn build_app_state(config: AppConfig) -> CoreResult<AppState> {
|
||||
let nats_client = async_nats::connect(&config.broker_url)
|
||||
.await
|
||||
.map_err(|e| CoreError::Config(format!("Failed to connect to NATS: {}", e)))?;
|
||||
@@ -30,6 +29,8 @@ pub async fn build_app_state(config: Config) -> CoreResult<AppState> {
|
||||
let media_repo = build_media_repository(&config, db_pool.clone()).await?;
|
||||
let album_repo = build_album_repository(&config.database, db_pool.clone()).await?;
|
||||
let album_share_repo = build_album_share_repository(&config.database, db_pool.clone()).await?;
|
||||
let media_metadata_repo =
|
||||
build_media_metadata_repository(&config.database, db_pool.clone()).await?;
|
||||
|
||||
let hasher = Arc::new(Argon2Hasher::default());
|
||||
let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone()));
|
||||
@@ -44,6 +45,7 @@ pub async fn build_app_state(config: Config) -> CoreResult<AppState> {
|
||||
media_repo.clone(),
|
||||
user_repo.clone(),
|
||||
album_share_repo.clone(),
|
||||
media_metadata_repo.clone(),
|
||||
config.clone(),
|
||||
nats_client.clone(),
|
||||
));
|
||||
|
||||
@@ -13,7 +13,7 @@ use tower::ServiceExt;
|
||||
use tower_http::services::ServeFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::MediaResponse, state::AppState};
|
||||
use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::{MediaDetailsResponse, MediaMetadataResponse, MediaResponse}, state::AppState};
|
||||
|
||||
|
||||
impl From<Media> for MediaResponse {
|
||||
@@ -101,9 +101,22 @@ async fn get_media_details(
|
||||
State(state): State<AppState>,
|
||||
UserId(user_id): UserId,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<MediaResponse>, ApiError> {
|
||||
let media = state.media_service.get_media_details(id, user_id).await?;
|
||||
Ok(Json(media.into()))
|
||||
) -> Result<Json<MediaDetailsResponse>, ApiError> {
|
||||
let bundle = state.media_service.get_media_details(id, user_id).await?;
|
||||
let response = MediaDetailsResponse {
|
||||
id: bundle.media.id,
|
||||
storage_path: bundle.media.storage_path,
|
||||
original_filename: bundle.media.original_filename,
|
||||
mime_type: bundle.media.mime_type,
|
||||
hash: bundle.media.hash,
|
||||
metadata: bundle.metadata
|
||||
.into_iter()
|
||||
.map(MediaMetadataResponse::from)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn delete_media(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use libertas_core::models::{Album, AlbumPermission};
|
||||
use libertas_core::{models::{Album, AlbumPermission, MediaMetadata}};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,9 +15,9 @@ pub struct MediaResponse {
|
||||
pub struct ListMediaParams {
|
||||
pub sort_by: Option<String>,
|
||||
pub order: Option<String>,
|
||||
// You can add future filters here, e.g.:
|
||||
// pub mime_type: Option<String>,
|
||||
}
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Vec<String>,}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAlbumRequest {
|
||||
@@ -92,4 +92,32 @@ pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MediaMetadataResponse {
|
||||
pub source: String,
|
||||
pub tag_name: String,
|
||||
pub tag_value: String,
|
||||
}
|
||||
|
||||
impl From<MediaMetadata> for MediaMetadataResponse {
|
||||
fn from(metadata: MediaMetadata) -> Self {
|
||||
Self {
|
||||
source: metadata.source.as_str().to_string(),
|
||||
tag_name: metadata.tag_name,
|
||||
tag_value: metadata.tag_value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MediaDetailsResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub storage_path: String,
|
||||
pub original_filename: String,
|
||||
pub mime_type: String,
|
||||
pub hash: String,
|
||||
pub metadata: Vec<MediaMetadataResponse>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::{path::{Path, PathBuf}, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Datelike;
|
||||
use futures::stream::StreamExt;
|
||||
use libertas_core::{
|
||||
authz,
|
||||
config::Config,
|
||||
error::{CoreError, CoreResult},
|
||||
models::Media,
|
||||
repositories::{AlbumShareRepository, MediaRepository, UserRepository},
|
||||
schema::{ListMediaOptions, UploadMediaData},
|
||||
services::MediaService,
|
||||
authz, config::AppConfig, error::{CoreError, CoreResult}, media_utils::{ExtractedExif, extract_exif_data_from_bytes, get_storage_path_and_date}, models::{Media, MediaBundle, MediaMetadata}, repositories::{AlbumShareRepository, MediaMetadataRepository, MediaRepository, UserRepository}, schema::{ListMediaOptions, UploadMediaData}, services::MediaService
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -21,7 +14,8 @@ pub struct MediaServiceImpl {
|
||||
repo: Arc<dyn MediaRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
album_share_repo: Arc<dyn AlbumShareRepository>,
|
||||
config: Config,
|
||||
metadata_repo: Arc<dyn MediaMetadataRepository>,
|
||||
config: AppConfig,
|
||||
nats_client: async_nats::Client,
|
||||
}
|
||||
|
||||
@@ -30,13 +24,15 @@ impl MediaServiceImpl {
|
||||
repo: Arc<dyn MediaRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
album_share_repo: Arc<dyn AlbumShareRepository>,
|
||||
config: Config,
|
||||
metadata_repo: Arc<dyn MediaMetadataRepository>,
|
||||
config: AppConfig,
|
||||
nats_client: async_nats::Client,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
user_repo,
|
||||
album_share_repo,
|
||||
metadata_repo,
|
||||
config,
|
||||
nats_client,
|
||||
}
|
||||
@@ -55,10 +51,22 @@ impl MediaService for MediaServiceImpl {
|
||||
self.check_upload_prerequisites(owner_id, file_size, &hash)
|
||||
.await?;
|
||||
|
||||
let storage_path = self.persist_media_file(&file_bytes, &filename).await?;
|
||||
let file_bytes_clone = file_bytes.clone();
|
||||
let extracted_data = tokio::task::spawn_blocking(move || {
|
||||
extract_exif_data_from_bytes(&file_bytes_clone)
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
||||
let (storage_path_buf, _date_taken) =
|
||||
get_storage_path_and_date(&extracted_data, &filename);
|
||||
|
||||
let storage_path_str = self
|
||||
.persist_media_file(&file_bytes, &storage_path_buf)
|
||||
.await?;
|
||||
|
||||
let media = self
|
||||
.persist_media_metadata(owner_id, filename, mime_type, storage_path, hash, file_size)
|
||||
.persist_media_metadata(owner_id, filename, mime_type, storage_path_str, hash, file_size, extracted_data)
|
||||
.await?;
|
||||
|
||||
self.publish_new_media_job(media.id).await?;
|
||||
@@ -66,7 +74,7 @@ impl MediaService for MediaServiceImpl {
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<Media> {
|
||||
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<MediaBundle> {
|
||||
let media = self
|
||||
.repo
|
||||
.find_by_id(id)
|
||||
@@ -79,20 +87,23 @@ impl MediaService for MediaServiceImpl {
|
||||
.await?
|
||||
.ok_or(CoreError::NotFound("User".to_string(), user_id))?;
|
||||
|
||||
if authz::is_owner(user_id, &media) || authz::is_admin(&user) {
|
||||
return Ok(media);
|
||||
}
|
||||
|
||||
let is_shared = self
|
||||
if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) {
|
||||
let is_shared = self
|
||||
.album_share_repo
|
||||
.is_media_in_shared_album(id, user_id)
|
||||
.await?;
|
||||
|
||||
if is_shared {
|
||||
return Ok(media);
|
||||
tracing::warn!("User {} attempted to access media {} without permission, media owner is: {}", user_id, id, media.owner_id);
|
||||
|
||||
if !is_shared {
|
||||
tracing::warn!("User {} attempted to access media {} without permission, media owner is: {}", user_id, id, media.owner_id);
|
||||
return Err(CoreError::Auth("Access denied".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(CoreError::Auth("Access denied".to_string()))
|
||||
let metadata = self.metadata_repo.find_by_media_id(id).await?;
|
||||
|
||||
Ok(MediaBundle { media, metadata })
|
||||
}
|
||||
|
||||
async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult<Vec<Media>> {
|
||||
@@ -216,27 +227,18 @@ impl MediaServiceImpl {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_media_file(&self, file_bytes: &[u8], filename: &str) -> CoreResult<String> {
|
||||
let now = chrono::Utc::now();
|
||||
let year = now.year().to_string();
|
||||
let month = format!("{:02}", now.month());
|
||||
async fn persist_media_file(&self, file_bytes: &[u8], storage_path: &Path) -> CoreResult<String> {
|
||||
let mut dest_path = PathBuf::from(&self.config.media_library_path);
|
||||
dest_path.push(year.clone());
|
||||
dest_path.push(month.clone());
|
||||
dest_path.push(storage_path);
|
||||
|
||||
fs::create_dir_all(&dest_path).await?;
|
||||
dest_path.push(filename);
|
||||
|
||||
let storage_path_str = PathBuf::from(&year)
|
||||
.join(&month)
|
||||
.join(filename)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let mut file = fs::File::create(&dest_path).await?;
|
||||
file.write_all(&file_bytes).await?;
|
||||
|
||||
Ok(storage_path_str)
|
||||
Ok(storage_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
async fn persist_media_metadata(
|
||||
@@ -247,6 +249,7 @@ impl MediaServiceImpl {
|
||||
storage_path: String,
|
||||
hash: String,
|
||||
file_size: i64,
|
||||
extracted_data: ExtractedExif,
|
||||
) -> CoreResult<Media> {
|
||||
let media_model = Media {
|
||||
id: Uuid::new_v4(),
|
||||
@@ -260,6 +263,22 @@ impl MediaServiceImpl {
|
||||
};
|
||||
|
||||
self.repo.create(&media_model).await?;
|
||||
|
||||
let mut metadata_models = Vec::new();
|
||||
for (source, tag_name, tag_value) in extracted_data.all_tags {
|
||||
metadata_models.push(MediaMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: media_model.id,
|
||||
source,
|
||||
tag_name,
|
||||
tag_value,
|
||||
});
|
||||
}
|
||||
|
||||
if !metadata_models.is_empty() {
|
||||
self.metadata_repo.create_batch(&metadata_models).await?;
|
||||
}
|
||||
|
||||
self.user_repo
|
||||
.update_storage_used(owner_id, file_size)
|
||||
.await?;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use libertas_core::{
|
||||
config::Config,
|
||||
config::AppConfig,
|
||||
error::{CoreError, CoreResult},
|
||||
models::{Role, User},
|
||||
repositories::UserRepository,
|
||||
@@ -17,7 +17,7 @@ pub struct UserServiceImpl {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
tokenizer: Arc<dyn TokenGenerator>,
|
||||
config: Arc<Config>,
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl UserServiceImpl {
|
||||
@@ -25,7 +25,7 @@ impl UserServiceImpl {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
tokenizer: Arc<dyn TokenGenerator>,
|
||||
config: Arc<Config>,
|
||||
config: Arc<AppConfig>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use libertas_core::{
|
||||
config::Config,
|
||||
config::AppConfig,
|
||||
services::{AlbumService, MediaService, UserService},
|
||||
};
|
||||
|
||||
@@ -14,5 +14,5 @@ pub struct AppState {
|
||||
pub album_service: Arc<dyn AlbumService>,
|
||||
pub token_generator: Arc<dyn TokenGenerator>,
|
||||
pub nats_client: async_nats::Client,
|
||||
pub config: Config,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user