This commit is contained in:
2025-11-02 09:31:01 +01:00
commit 455e144ffb
37 changed files with 4193 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use libertas_core::{
error::{CoreError, CoreResult},
models::Album,
repositories::{AlbumRepository, MediaRepository},
schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData},
services::AlbumService,
};
use uuid::Uuid;
pub struct AlbumServiceImpl {
album_repo: Arc<dyn AlbumRepository>,
media_repo: Arc<dyn MediaRepository>,
}
impl AlbumServiceImpl {
pub fn new(album_repo: Arc<dyn AlbumRepository>, media_repo: Arc<dyn MediaRepository>) -> Self {
Self {
album_repo,
media_repo,
}
}
async fn is_album_owner(&self, user_id: Uuid, album_id: Uuid) -> CoreResult<bool> {
let album = self
.album_repo
.find_by_id(album_id)
.await?
.ok_or(CoreError::NotFound("Album".to_string(), album_id))?;
Ok(album.owner_id == user_id)
}
}
#[async_trait]
impl AlbumService for AlbumServiceImpl {
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<()> {
if data.name.is_empty() {
return Err(CoreError::Validation(
"Album name cannot be empty".to_string(),
));
}
let now = Utc::now();
let album = Album {
id: Uuid::new_v4(),
owner_id: data.owner_id,
name: data.name.to_string(),
description: data.description.map(String::from),
is_public: data.is_public,
created_at: now,
updated_at: now,
};
self.album_repo.create(album).await
}
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album> {
let album = self
.album_repo
.find_by_id(album_id)
.await?
.ok_or(CoreError::NotFound("Album".to_string(), album_id))?;
// Security check: Only owner (for now) can see album details
if album.owner_id != user_id {
// Later, this would also check share permissions
return Err(CoreError::Auth("Access denied to album".to_string()));
}
Ok(album)
}
async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()> {
// 1. Verify the user owns the album
if !self.is_album_owner(user_id, data.album_id).await? {
return Err(CoreError::Auth("User does not own this album".to_string()));
}
// 2. Bonus: Verify the user owns all media items
for media_id in &data.media_ids {
let media = self
.media_repo
.find_by_id(*media_id)
.await?
.ok_or(CoreError::NotFound("Media".to_string(), *media_id))?;
if media.owner_id != user_id {
return Err(CoreError::Auth(format!(
"Access denied to media item {}",
media_id
)));
}
}
// 3. Call the repository to add them
self.album_repo
.add_media_to_album(data.album_id, &data.media_ids)
.await
}
async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>> {
self.album_repo.list_by_user(user_id).await
}
async fn share_album(&self, _data: ShareAlbumData, _owner_id: Uuid) -> CoreResult<()> {
// This is not part of the MVP, but part of the trait
unimplemented!("Sharing will be implemented in a future phase")
}
}

View File

@@ -0,0 +1,112 @@
use std::{path::PathBuf, sync::Arc};
use async_trait::async_trait;
use chrono::Datelike;
use futures::stream::StreamExt;
use libertas_core::{
error::{CoreError, CoreResult},
models::Media,
repositories::MediaRepository,
schema::UploadMediaData,
services::MediaService,
};
use sha2::{Digest, Sha256};
use tokio::{fs, io::AsyncWriteExt};
use uuid::Uuid;
use crate::config::Config;
pub struct MediaServiceImpl {
repo: Arc<dyn MediaRepository>,
config: Config,
}
impl MediaServiceImpl {
pub fn new(repo: Arc<dyn MediaRepository>, config: Config) -> Self {
Self { repo, config }
}
}
#[async_trait]
impl MediaService for MediaServiceImpl {
async fn upload_media(&self, mut data: UploadMediaData<'_>) -> CoreResult<Media> {
let mut hasher = Sha256::new();
let mut file_bytes = Vec::new();
while let Some(chunk_result) = data.stream.next().await {
let chunk = chunk_result.map_err(|e| CoreError::Io(e))?;
hasher.update(&chunk);
file_bytes.extend_from_slice(&chunk);
}
let hash = format!("{:x}", hasher.finalize());
if self.repo.find_by_hash(&hash).await?.is_some() {
return Err(CoreError::Duplicate(
"A file with this content already exists".to_string(),
));
}
let now = chrono::Utc::now();
let year = now.year().to_string();
let month = format!("{:02}", now.month());
let mut dest_path = PathBuf::from(&self.config.media_library_path);
dest_path.push(year.clone());
dest_path.push(month.clone());
fs::create_dir_all(&dest_path)
.await
.map_err(|e| CoreError::Io(e))?;
dest_path.push(&data.filename);
let storage_path_str = PathBuf::from(&year)
.join(&month)
.join(&data.filename)
.to_string_lossy()
.to_string();
let mut file = fs::File::create(&dest_path)
.await
.map_err(|e| CoreError::Io(e))?;
file.write_all(&file_bytes)
.await
.map_err(|e| CoreError::Io(e))?;
let media_model = Media {
id: Uuid::new_v4(),
owner_id: data.owner_id,
storage_path: storage_path_str,
original_filename: data.filename,
mime_type: data.mime_type,
hash,
created_at: now,
extracted_location: None,
width: None,
height: None,
};
self.repo.create(&media_model).await?;
Ok(media_model)
}
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<Media> {
let media = self
.repo
.find_by_id(id)
.await?
.ok_or(CoreError::NotFound("Media".to_string(), id))?;
if media.owner_id != user_id {
return Err(CoreError::Auth("Access denied".to_string()));
}
Ok(media)
}
async fn list_user_media(&self, user_id: Uuid) -> CoreResult<Vec<Media>> {
self.repo.list_by_user(user_id).await
}
}

View File

@@ -0,0 +1,3 @@
pub mod album_service;
pub mod media_service;
pub mod user_service;

View File

@@ -0,0 +1,89 @@
use std::sync::Arc;
use async_trait::async_trait;
use libertas_core::{
error::{CoreError, CoreResult},
models::User,
repositories::UserRepository,
schema::{CreateUserData, LoginUserData},
services::UserService,
};
use uuid::Uuid;
use crate::security::{PasswordHasher, TokenGenerator};
pub struct UserServiceImpl {
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
tokenizer: Arc<dyn TokenGenerator>,
}
impl UserServiceImpl {
pub fn new(
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
tokenizer: Arc<dyn TokenGenerator>,
) -> Self {
Self {
repo,
hasher,
tokenizer,
}
}
}
#[async_trait]
impl UserService for UserServiceImpl {
async fn register(&self, data: CreateUserData<'_>) -> CoreResult<User> {
if data.username.is_empty() || data.email.is_empty() || data.password.is_empty() {
return Err(CoreError::Validation(
"Username, email, and password cannot be empty".to_string(),
));
}
if self.repo.find_by_email(data.email).await?.is_some() {
return Err(CoreError::Duplicate("Email already exists".to_string()));
}
if self.repo.find_by_username(data.username).await?.is_some() {
return Err(CoreError::Duplicate("Username already exists".to_string()));
}
let hashed_password = self.hasher.hash_password(data.password).await?;
let user = User {
id: Uuid::new_v4(),
username: data.username.to_string(),
email: data.email.to_string(),
hashed_password,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.repo.create(user.clone()).await?;
Ok(user)
}
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String> {
let user = self
.repo
.find_by_email(data.username_or_email) // Allow login with email
.await?
.or(self.repo.find_by_username(data.username_or_email).await?) // Or username
.ok_or(CoreError::Auth("Invalid credentials".to_string()))?;
self.hasher
.verify_password(data.password, &user.hashed_password)
.await?;
// 3. Generate JWT token
self.tokenizer.generate_token(user.id)
}
async fn get_user_details(&self, user_id: Uuid) -> CoreResult<User> {
self.repo
.find_by_id(user_id)
.await?
.ok_or(CoreError::NotFound("User".to_string(), user_id))
}
}