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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
target/
.sqlx/
media_library/

2646
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = ["libertas_api", "libertas_core"]

View File

@@ -0,0 +1 @@
DATABASE_URL="postgres://postgres:postgres@localhost:5432/libertas_db"

4
libertas_api/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
media_library/
.sqlx/
.env

32
libertas_api/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "libertas_api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.6", features = ["multipart"] }
tokio = { version = "1.48.0", features = ["full"] }
libertas_core = { path = "../libertas_core" }
serde = { version = "1.0.228", features = ["derive"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio",
"postgres",
"uuid",
"chrono",
"sqlite",
] }
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.18.1", features = ["v4", "serde"] }
anyhow = "1.0.100"
argon2 = "0.5.3"
jsonwebtoken = { version = "10.1.0", features = ["rust_crypto"] }
once_cell = "1.21.3"
serde_json = "1.0.145"
headers = "0.4.1"
axum-extra = { version = "0.12.0", features = ["typed-header"] }
rand_core = { version = "0.9.3", features = ["std"] }
sha2 = "0.10.9"
futures = "0.3.31"
bytes = "1.10.1"

View File

@@ -0,0 +1,9 @@
-- Active: 1762068220033@@127.0.0.1@5432@libertas_db
CREATE TABLE users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,16 @@
CREATE TABLE media (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES users(id),
storage_path TEXT NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE, -- For duplicate checking
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Optional fields from the model
extracted_location TEXT, width INTEGER, height INTEGER );
-- Indexes for faster lookups
CREATE INDEX idx_media_owner_id ON media (owner_id);
CREATE INDEX idx_media_hash ON media (hash);

View File

@@ -0,0 +1,23 @@
-- Create the 'albums' table
CREATE TABLE albums (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES users (id),
name TEXT NOT NULL,
description TEXT,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create the 'album_media' join table
-- This links media items to albums
CREATE TABLE album_media (
album_id UUID NOT NULL REFERENCES albums (id) ON DELETE CASCADE,
media_id UUID NOT NULL REFERENCES media (id) ON DELETE CASCADE,
PRIMARY KEY (album_id, media_id) -- Ensures no duplicates
);
-- Indexes for faster lookups
CREATE INDEX idx_albums_owner_id ON albums (owner_id);
CREATE INDEX idx_album_media_media_id ON album_media (media_id);

View File

@@ -0,0 +1,34 @@
use libertas_core::error::CoreResult;
use serde::Deserialize;
#[derive(Deserialize, Clone)]
pub enum DatabaseType {
Postgres,
Sqlite,
}
#[derive(Deserialize, Clone)]
pub struct DatabaseConfig {
pub db_type: DatabaseType,
pub url: String,
}
#[derive(Deserialize, Clone)]
pub struct Config {
pub database: DatabaseConfig,
pub server_address: String,
pub jwt_secret: String,
pub media_library_path: String,
}
pub fn load_config() -> CoreResult<Config> {
Ok(Config {
database: DatabaseConfig {
db_type: DatabaseType::Postgres,
url: "postgres://postgres:postgres@localhost:5432/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(),
})
}

43
libertas_api/src/error.rs Normal file
View File

@@ -0,0 +1,43 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use libertas_core::error::CoreError;
use serde_json::json;
pub struct ApiError(CoreError);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self.0 {
CoreError::Database(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
),
CoreError::Validation(e) => (StatusCode::BAD_REQUEST, e),
CoreError::NotFound(res, id) => (
StatusCode::NOT_FOUND,
format!("{} with id {} not found", res, id),
),
CoreError::Duplicate(e) => (StatusCode::CONFLICT, e),
CoreError::Auth(e) => (StatusCode::UNAUTHORIZED, e),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"An unknown error occurred".to_string(),
),
};
let body = Json(json!({ "error": error_message }));
(status, body).into_response()
}
}
impl<E> From<E> for ApiError
where
E: Into<CoreError>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

105
libertas_api/src/factory.rs Normal file
View File

@@ -0,0 +1,105 @@
use std::sync::Arc;
use libertas_core::{
error::{CoreError, CoreResult},
repositories::UserRepository,
};
use sqlx::{Pool, Postgres, Sqlite};
use crate::{
config::{Config, DatabaseConfig, DatabaseType},
repositories::user_repository::{PostgresUserRepository, SqliteUserRepository},
security::{Argon2Hasher, JwtGenerator},
services::{
album_service::AlbumServiceImpl, media_service::MediaServiceImpl,
user_service::UserServiceImpl,
},
state::AppState,
};
#[derive(Clone)]
enum DatabasePool {
Postgres(Pool<Postgres>),
Sqlite(Pool<Sqlite>),
}
pub async fn build_app_state(config: Config) -> CoreResult<AppState> {
let db_pool = build_database_pool(&config.database).await?;
let user_repo = build_user_repository(&config.database, db_pool.clone()).await?;
let media_repo = build_media_repository(&config.database, db_pool.clone()).await?;
let album_repo = build_album_repository(&config.database, db_pool.clone()).await?;
let hasher = Arc::new(Argon2Hasher::default());
let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone()));
let user_service = Arc::new(UserServiceImpl::new(user_repo, hasher, tokenizer.clone()));
let media_service = Arc::new(MediaServiceImpl::new(media_repo.clone(), config.clone()));
let album_service = Arc::new(AlbumServiceImpl::new(album_repo, media_repo));
Ok(AppState {
user_service,
media_service,
album_service,
token_generator: tokenizer,
})
}
async fn build_database_pool(db_config: &DatabaseConfig) -> CoreResult<DatabasePool> {
match db_config.db_type {
DatabaseType::Postgres => {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(50)
.connect(&db_config.url)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(DatabasePool::Postgres(pool))
}
DatabaseType::Sqlite => {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(2)
.connect(&db_config.url)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(DatabasePool::Sqlite(pool))
}
}
}
async fn build_user_repository(
_db_config: &DatabaseConfig,
pool: DatabasePool,
) -> CoreResult<Arc<dyn UserRepository>> {
match pool {
DatabasePool::Postgres(pg_pool) => Ok(Arc::new(PostgresUserRepository::new(pg_pool))),
DatabasePool::Sqlite(sqlite_pool) => Ok(Arc::new(SqliteUserRepository::new(sqlite_pool))),
}
}
async fn build_media_repository(
_db_config: &DatabaseConfig,
pool: DatabasePool,
) -> CoreResult<Arc<dyn libertas_core::repositories::MediaRepository>> {
match pool {
DatabasePool::Postgres(pg_pool) => Ok(Arc::new(
crate::repositories::media_repository::PostgresMediaRepository::new(pg_pool),
)),
DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database(
"Sqlite media repository not implemented".to_string(),
)),
}
}
async fn build_album_repository(
_db_config: &DatabaseConfig,
pool: DatabasePool,
) -> CoreResult<Arc<dyn libertas_core::repositories::AlbumRepository>> {
match pool {
DatabasePool::Postgres(pg_pool) => Ok(Arc::new(
crate::repositories::album_repository::PostgresAlbumRepository::new(pg_pool),
)),
DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database(
"Sqlite album repository not implemented".to_string(),
)),
}
}

View File

@@ -0,0 +1,64 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
};
use libertas_core::schema::{AddMediaToAlbumData, CreateAlbumData};
use serde::Deserialize;
use uuid::Uuid;
use crate::{error::ApiError, middleware::auth::UserId, state::AppState};
#[derive(Deserialize)]
pub struct CreateAlbumRequest {
name: String,
description: Option<String>,
is_public: Option<bool>,
}
async fn create_album(
State(state): State<AppState>,
UserId(user_id): UserId,
Json(payload): Json<CreateAlbumRequest>,
) -> Result<StatusCode, ApiError> {
let album_data = CreateAlbumData {
owner_id: user_id,
name: &payload.name,
description: payload.description.as_deref(),
is_public: payload.is_public.unwrap_or(false),
};
state.album_service.create_album(album_data).await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
pub struct AddMediaToAlbumRequest {
media_ids: Vec<Uuid>,
}
async fn add_media_to_album(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
Json(payload): Json<AddMediaToAlbumRequest>,
) -> Result<StatusCode, ApiError> {
let data = AddMediaToAlbumData {
album_id,
media_ids: payload.media_ids,
};
state
.album_service
.add_media_to_album(data, user_id)
.await?;
Ok(StatusCode::OK)
}
pub fn album_routes() -> Router<AppState> {
Router::new()
.route("/", axum::routing::post(create_album))
.route("/{album_id}/media", axum::routing::post(add_media_to_album))
}

View File

@@ -0,0 +1,79 @@
use axum::{Json, extract::State, http::StatusCode};
use libertas_core::schema::{CreateUserData, LoginUserData};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{error::ApiError, middleware::auth::UserId, state::AppState};
#[derive(Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct UserResponse {
id: Uuid,
username: String,
email: String,
}
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<UserResponse>), ApiError> {
let user_data = CreateUserData {
username: &payload.username,
email: &payload.email,
password: &payload.password,
};
let user = state.user_service.register(user_data).await?;
let response = UserResponse {
id: user.id,
username: user.username,
email: user.email,
};
Ok((StatusCode::CREATED, Json(response)))
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub username_or_email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
token: String,
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
let login_data = LoginUserData {
username_or_email: &payload.username_or_email,
password: &payload.password,
};
let token = state.user_service.login(login_data).await?;
Ok(Json(LoginResponse { token }))
}
pub async fn get_me(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<UserResponse>, ApiError> {
let user = state.user_service.get_user_details(user_id).await?;
let response = UserResponse {
id: user.id,
username: user.username,
email: user.email,
};
Ok(Json(response))
}

View File

@@ -0,0 +1,77 @@
use axum::{
Router,
extract::{DefaultBodyLimit, Multipart, State},
http::StatusCode,
response::Json,
routing::post,
};
use futures::TryStreamExt;
use libertas_core::{error::CoreError, models::Media, schema::UploadMediaData};
use serde::Serialize;
use std::io;
use crate::{error::ApiError, middleware::auth::UserId, state::AppState};
#[derive(Serialize)]
pub struct MediaResponse {
id: uuid::Uuid,
storage_path: String,
original_filename: String,
mime_type: String,
hash: String,
}
impl From<Media> for MediaResponse {
fn from(media: Media) -> Self {
Self {
id: media.id,
storage_path: media.storage_path,
original_filename: media.original_filename,
mime_type: media.mime_type,
hash: media.hash,
}
}
}
pub fn media_routes() -> Router<AppState> {
Router::new()
.route("/", post(upload_media))
.layer(DefaultBodyLimit::max(250 * 1024 * 1024))
}
async fn upload_media(
State(state): State<AppState>,
UserId(user_id): UserId,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<MediaResponse>), ApiError> {
let field = multipart
.next_field()
.await
.map_err(|e| CoreError::Validation(format!("Multipart error: {}", e)))?
.ok_or(ApiError::from(CoreError::Validation(
"No file provided in 'file' field".to_string(),
)))?;
let filename = field.file_name().unwrap_or("unknown_file").to_string();
let mime_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let stream = field.map_err(|e| io::Error::new(io::ErrorKind::Other, e));
let boxed_stream: Box<
dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send + Unpin,
> = Box::new(stream);
let upload_data = UploadMediaData {
owner_id: user_id,
filename,
mime_type,
stream: boxed_stream,
};
let media = state.media_service.upload_media(upload_data).await?;
Ok((StatusCode::CREATED, Json(media.into())))
}

View File

@@ -0,0 +1,3 @@
pub mod album_handlers;
pub mod auth_handlers;
pub mod media_handlers;

53
libertas_api/src/main.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::net::SocketAddr;
use axum::{
Router,
routing::{get, post},
};
use crate::handlers::{
album_handlers, auth_handlers,
media_handlers::{self},
};
pub mod config;
pub mod error;
pub mod factory;
pub mod handlers;
pub mod middleware;
pub mod repositories;
pub mod security;
pub mod services;
pub mod state;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = config::load_config()?;
let addr: SocketAddr = config.server_address.parse()?;
let app_state = factory::build_app_state(config).await?;
let auth_routes = Router::new()
.route("/register", post(auth_handlers::register))
.route("/login", post(auth_handlers::login));
let user_routes = Router::new().route("/me", get(auth_handlers::get_me));
let media_routes = media_handlers::media_routes();
let album_routes = album_handlers::album_routes();
let app = Router::new()
.route("/api/v1/health", get(|| async { "OK" }))
.nest("/api/v1/auth", auth_routes)
.nest("/api/v1/users", user_routes)
.nest("/api/v1/media", media_routes)
.nest("/api/v1/albums", album_routes)
.with_state(app_state);
println!("Starting server at http://{}", addr);
let listner = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listner, app.into_make_service()).await?;
Ok(())
}

View File

@@ -0,0 +1,49 @@
use axum::{
RequestPartsExt,
extract::FromRequestParts,
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use axum_extra::TypedHeader;
use axum_extra::headers::{Authorization, authorization::Bearer};
use libertas_core::error::{CoreError, CoreResult};
use uuid::Uuid;
use crate::{security::TokenGenerator, state::AppState};
use std::sync::Arc;
pub struct UserId(pub Uuid);
impl FromRequestParts<AppState> for UserId {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tokenizer: Arc<dyn TokenGenerator> = state.token_generator.clone();
let result = (async || -> CoreResult<Uuid> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| CoreError::Auth("Missing Authorization header".to_string()))?;
let user_id = tokenizer.verify_token(bearer.token())?;
Ok(user_id)
})()
.await;
match result {
Ok(user_id) => Ok(Self(user_id)),
Err(e) => {
let status = match e {
CoreError::Auth(_) => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
Err((status, e.to_string()).into_response())
}
}
}
}

View File

@@ -0,0 +1 @@
pub mod auth;

View File

@@ -0,0 +1,91 @@
use async_trait::async_trait;
use libertas_core::{
error::{CoreError, CoreResult},
models::Album,
repositories::AlbumRepository,
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
pub struct PostgresAlbumRepository {
pool: PgPool,
}
impl PostgresAlbumRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AlbumRepository for PostgresAlbumRepository {
async fn create(&self, album: Album) -> CoreResult<()> {
sqlx::query!(
r#"
INSERT INTO albums (id, owner_id, name, description, is_public, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
album.id,
album.owner_id,
album.name,
album.description,
album.is_public,
album.created_at,
album.updated_at
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>> {
sqlx::query_as!(
Album,
r#"
SELECT id, owner_id, name, description, is_public, created_at, updated_at
FROM albums
WHERE id = $1
"#,
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>> {
sqlx::query_as!(
Album,
r#"
SELECT id, owner_id, name, description, is_public, created_at, updated_at
FROM albums
WHERE owner_id = $1
"#,
user_id
)
.fetch_all(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()> {
// Use sqlx's `unnest` feature to pass the Vec<Uuid> efficiently
sqlx::query!(
r#"
INSERT INTO album_media (album_id, media_id)
SELECT $1, media_id FROM unnest($2::uuid[]) as media_id
ON CONFLICT (album_id, media_id) DO NOTHING
"#,
album_id,
media_ids
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,93 @@
use async_trait::async_trait;
use libertas_core::{
error::{CoreError, CoreResult},
models::Media,
repositories::MediaRepository,
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
pub struct PostgresMediaRepository {
pool: PgPool,
}
impl PostgresMediaRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl MediaRepository for PostgresMediaRepository {
async fn create(&self, media: &Media) -> CoreResult<()> {
sqlx::query!(
r#"
INSERT INTO media (id, owner_id, storage_path, original_filename, mime_type, hash, created_at, width, height)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
media.id,
media.owner_id,
media.storage_path,
media.original_filename,
media.mime_type,
media.hash,
media.created_at,
media.width,
media.height
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
async fn find_by_hash(&self, hash: &str) -> CoreResult<Option<Media>> {
sqlx::query_as!(
Media,
r#"
SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at,
extracted_location, width, height
FROM media
WHERE hash = $1
"#,
hash
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Media>> {
sqlx::query_as!(
Media,
r#"
SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at,
extracted_location, width, height
FROM media
WHERE id = $1
"#,
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Media>> {
sqlx::query_as!(
Media,
r#"
SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at,
extracted_location, width, height
FROM media
WHERE owner_id = $1
"#,
user_id
)
.fetch_all(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
}

View File

@@ -0,0 +1,3 @@
pub mod album_repository;
pub mod media_repository;
pub mod user_repository;

View File

@@ -0,0 +1,96 @@
use async_trait::async_trait;
use libertas_core::{
error::{CoreError, CoreResult},
models::User,
repositories::UserRepository,
};
use sqlx::{PgPool, SqlitePool, types::Uuid};
#[derive(Clone)]
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(Clone)]
pub struct SqliteUserRepository {
_pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { _pool: pool }
}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn create(&self, user: User) -> CoreResult<()> {
sqlx::query!(
r#"
INSERT INTO users (id, username, email, hashed_password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
user.id,
user.username,
user.email,
user.hashed_password,
user.created_at,
user.updated_at
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
async fn find_by_email(&self, email: &str) -> CoreResult<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", email)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE username = $1", username)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))
}
}
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn create(&self, _user: User) -> CoreResult<()> {
println!("SQLITE REPO: Creating user");
Ok(())
}
async fn find_by_email(&self, _email: &str) -> CoreResult<Option<User>> {
println!("SQLITE REPO: Finding user by email");
Ok(None)
}
async fn find_by_username(&self, _username: &str) -> CoreResult<Option<User>> {
println!("SQLITE REPO: Finding user by username");
Ok(None)
}
async fn find_by_id(&self, _id: Uuid) -> CoreResult<Option<User>> {
println!("SQLITE REPO: Finding user by id");
Ok(None)
}
}

View File

@@ -0,0 +1,115 @@
use argon2::{
Argon2,
password_hash::{
PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, rand_core::OsRng,
},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use libertas_core::error::{CoreError, CoreResult};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash_password(&self, password: &str) -> CoreResult<String>;
async fn verify_password(&self, password: &str, hash: &str) -> CoreResult<()>;
}
pub struct Argon2Hasher;
impl Default for Argon2Hasher {
fn default() -> Self {
Self {}
}
}
#[async_trait]
impl PasswordHasher for Argon2Hasher {
async fn hash_password(&self, password: &str) -> CoreResult<String> {
let salt = SaltString::generate(&mut OsRng);
let password_bytes = password.as_bytes().to_vec();
let hash_string = tokio::task::spawn_blocking(move || {
Argon2::default()
.hash_password(&password_bytes, &salt)
.map(|hash| hash.to_string())
.map_err(|e| CoreError::Auth(format!("Password hashing failed: {}", e)))
})
.await
.unwrap()?;
Ok(hash_string)
}
async fn verify_password(&self, password: &str, hash_str: &str) -> CoreResult<()> {
let hash = PasswordHash::new(hash_str)
.map_err(|e| CoreError::Auth(format!("Invalid password hash format: {}", e)))?;
let (password_bytes, hash_bytes) = (password.as_bytes().to_vec(), hash.to_string());
tokio::task::spawn_blocking(move || {
Argon2::default()
.verify_password(&password_bytes, &PasswordHash::new(&hash_bytes).unwrap())
.map_err(|_| CoreError::Auth("Invalid password".to_string()))
})
.await
.unwrap()
}
}
static JWT_HEADER: Lazy<Header> = Lazy::new(Header::default);
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Claims {
sub: Uuid,
exp: usize,
iat: usize,
}
#[async_trait]
pub trait TokenGenerator: Send + Sync {
fn generate_token(&self, user_id: Uuid) -> CoreResult<String>;
fn verify_token(&self, token: &str) -> CoreResult<Uuid>;
}
pub struct JwtGenerator {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
}
impl JwtGenerator {
pub fn new(secret: String) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
}
}
}
#[async_trait]
impl TokenGenerator for JwtGenerator {
fn generate_token(&self, user_id: Uuid) -> CoreResult<String> {
let now = Utc::now();
let iat = now.timestamp() as usize;
let exp = (now + Duration::days(7)).timestamp() as usize;
let claims = Claims {
sub: user_id,
iat,
exp,
};
encode(&JWT_HEADER, &claims, &self.encoding_key)
.map_err(|e| CoreError::Auth(format!("Token generation failed: {}", e)))
}
fn verify_token(&self, token: &str) -> CoreResult<Uuid> {
decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map(|data| data.claims.sub)
.map_err(|e| CoreError::Auth(format!("Token invalid: {}", e)))
}
}

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

13
libertas_api/src/state.rs Normal file
View File

@@ -0,0 +1,13 @@
use std::sync::Arc;
use libertas_core::services::{AlbumService, MediaService, UserService};
use crate::security::TokenGenerator;
#[derive(Clone)]
pub struct AppState {
pub user_service: Arc<dyn UserService>,
pub media_service: Arc<dyn MediaService>,
pub album_service: Arc<dyn AlbumService>,
pub token_generator: Arc<dyn TokenGenerator>,
}

1
libertas_core/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

13
libertas_core/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "libertas_core"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
bytes = "1.10.1"
chrono = "0.4.42"
futures = "0.3.31"
thiserror = "2.0.17"
uuid = "1.18.1"

View File

@@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CoreError {
#[error("Configuration error: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database error: {0}")]
Database(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("{0} not found with id: {1}")]
NotFound(String, uuid::Uuid),
#[error("Duplicate resource: {0}")]
Duplicate(String),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("An unknown error occurred")]
Unknown,
}
pub type CoreResult<T> = Result<T, CoreError>;

5
libertas_core/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod error;
pub mod models;
pub mod repositories;
pub mod schema;
pub mod services;

View File

@@ -0,0 +1,67 @@
pub struct Media {
pub id: uuid::Uuid,
pub owner_id: uuid::Uuid,
pub storage_path: String,
pub original_filename: String,
pub mime_type: String,
pub hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub extracted_location: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[derive(Clone)]
pub struct User {
pub id: uuid::Uuid,
pub username: String,
pub email: String,
pub hashed_password: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
pub struct Album {
pub id: uuid::Uuid,
pub owner_id: uuid::Uuid,
pub name: String,
pub description: Option<String>,
pub is_public: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
pub struct Person {
pub id: uuid::Uuid,
pub owner_id: uuid::Uuid,
pub name: String,
pub thumbnail_media_id: Option<uuid::Uuid>,
}
pub struct FaceRegion {
pub id: uuid::Uuid,
pub media_id: uuid::Uuid,
pub person_id: Option<uuid::Uuid>,
pub x_min: f32,
pub y_min: f32,
pub x_max: f32,
pub y_max: f32,
}
pub struct AlbumMedia {
pub album_id: uuid::Uuid,
pub media_id: uuid::Uuid,
}
#[derive(Clone, Copy)]
pub enum AlbumPermission {
View,
Contribute,
}
pub struct AlbumShare {
pub album_id: uuid::Uuid,
pub user_id: uuid::Uuid,
pub permission: AlbumPermission,
}

View File

@@ -0,0 +1,31 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{
error::CoreResult,
models::{Album, Media, User},
};
#[async_trait]
pub trait MediaRepository: Send + Sync {
async fn find_by_hash(&self, hash: &str) -> CoreResult<Option<Media>>;
async fn create(&self, media: &Media) -> CoreResult<()>;
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Media>>;
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Media>>;
}
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn create(&self, user: User) -> CoreResult<()>;
async fn find_by_email(&self, email: &str) -> CoreResult<Option<User>>;
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>>;
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<User>>;
}
#[async_trait]
pub trait AlbumRepository: Send + Sync {
async fn create(&self, album: Album) -> CoreResult<()>;
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>>;
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>;
}

View File

@@ -0,0 +1,38 @@
use crate::models::AlbumPermission;
pub struct UploadMediaData<'a> {
pub owner_id: uuid::Uuid,
pub filename: String,
pub mime_type: String,
pub stream:
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send + Unpin + 'a>,
}
pub struct CreateUserData<'a> {
pub username: &'a str,
pub email: &'a str,
pub password: &'a str,
}
pub struct LoginUserData<'a> {
pub username_or_email: &'a str,
pub password: &'a str,
}
pub struct CreateAlbumData<'a> {
pub owner_id: uuid::Uuid,
pub name: &'a str,
pub description: Option<&'a str>,
pub is_public: bool,
}
pub struct AddMediaToAlbumData {
pub album_id: uuid::Uuid,
pub media_ids: Vec<uuid::Uuid>,
}
pub struct ShareAlbumData {
pub album_id: uuid::Uuid,
pub target_user_id: uuid::Uuid,
pub permission: AlbumPermission,
}

View File

@@ -0,0 +1,35 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{
error::CoreResult,
models::{Album, Media, User},
schema::{
AddMediaToAlbumData, CreateAlbumData, CreateUserData, LoginUserData, ShareAlbumData,
UploadMediaData,
},
};
#[async_trait]
pub trait MediaService: Send + Sync {
async fn upload_media(&self, data: UploadMediaData<'_>) -> CoreResult<Media>;
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<Media>;
async fn list_user_media(&self, user_id: Uuid) -> CoreResult<Vec<Media>>;
}
#[async_trait]
pub trait UserService: Send + Sync {
async fn register(&self, data: CreateUserData<'_>) -> CoreResult<User>;
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String>;
async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult<User>;
}
#[async_trait]
pub trait AlbumService: Send + Sync {
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<()>;
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album>;
async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()>;
async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
async fn share_album(&self, data: ShareAlbumData, owner_id: Uuid) -> CoreResult<()>;
}