adding user authentication

This commit is contained in:
DioCrafts
2025-03-20 09:22:31 +01:00
parent 961545aef7
commit cafad0fbfd
69 changed files with 6483 additions and 106 deletions
+11 -9
View File
@@ -28,19 +28,21 @@ RUST_BACKTRACE=1 cargo run # Run with full backtrace for better error diagnosti
## Code Style Guidelines
- **Architecture**: Follow Clean Architecture with clear layer separation (domain → application → infrastructure → interfaces)
- **Naming**: Use `snake_case` for files, modules, functions, variables; `PascalCase` for types/structs/enums
- **Naming**: Use `snake_case` for files, modules, functions, variables; `PascalCase` for types/structs/enums; getters without `get_` prefix
- **Modules**: Use mod.rs files for explicit exports with visibility modifiers (pub, pub(crate))
- **Error Handling**: Use Result<T, E> with thiserror for custom error types; propagate errors with ? operator
- **Comments**: Document public APIs with /// doc comments, explain "why" not "what"
- **Error Handling**: Use Result<T, E> with thiserror for custom error types; propagate errors with ? operator; include context in error messages
- **Documentation**: Document public APIs with /// doc comments, explain "why" not "what"; both English and Spanish comments are acceptable
- **Imports**: Group imports: 1) std, 2) external crates, 3) internal modules (with blank lines between)
- **Async**: Use async-trait for repository interfaces; handle futures with .await and tokio runtime
- **Testing**: Write unit tests in the same file as implementation (bottom of file, in a tests module)
- **Dependencies**: Use axum for web API, tower-http for middleware, serde for serialization
- **Logging**: Use tracing with appropriate levels (debug, info, warn, error) and structured contexts
- **Repository Pattern**: Define interfaces in domain layer, implement in infrastructure layer
- **Async**: Use async-trait for repository interfaces; handle futures with .await and tokio runtime; implement timeouts for I/O operations
- **Testing**: Write unit tests in the same file as implementation (bottom of file, in a tests module with #[cfg(test)])
- **Dependencies**: Use axum for web API, tower-http for middleware, serde for serialization; share dependencies with Arc
- **Logging**: Use tracing with appropriate levels (debug, info, warn, error) and structured contexts for detailed diagnostics
- **Repository Pattern**: Define interfaces in domain layer, implement in infrastructure layer; use traits with dynamic dispatch (Box<dyn Trait>)
- **I18n**: Store translations in JSON files under static/locales/, use i18n service for text lookups
- **Type Safety**: Prefer strong typing with domain-specific types over primitive types
- **Type Safety**: Prefer strong typing with domain-specific types over primitive types; validate at construction time
- **Error Messages**: Provide clear, actionable error messages that help diagnose the issue
- **Immutability**: Prefer immutable data structures; use with_* methods to return modified copies rather than mutating in place
- **Performance**: Implement caching with proper invalidation; use parallel processing for large file operations; optimize based on file sizes
## Project Structure
OxiCloud is a NextCloud-like file storage system built in Rust with a focus on performance and security. It provides a clean REST API and web interface for file management using a layered architecture approach:
Generated
+1179 -31
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -4,14 +4,14 @@ version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8.1", features = ["multipart"] }
axum = { version = "0.8.1", features = ["multipart", "http1", "tokio"] }
tokio = { version = "1.44.1", features = ["full"] }
tokio-util = { version = "0.7.14", features = ["io", "codec"] }
tokio-stream = { version = "0.1.15", features = ["fs"] }
bytes = "1.6.0"
tempfile = "3.10.1"
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["fs", "compression-gzip", "trace", "cors", "add-extension"] }
tower-http = { version = "0.6.2", features = ["fs", "compression-gzip", "trace", "cors", "add-extension", "request-id"] }
flate2 = "1.0.28"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
@@ -29,6 +29,13 @@ reqwest = { version = "0.12.5", features = ["json", "multipart"] }
mockall = { version = "0.12.1", optional = true }
rand = "0.8.5"
pin-project-lite = "0.2.13"
sqlx = { version = "0.7.3", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json"] }
anyhow = "1.0.81"
jsonwebtoken = "9.2.0"
argon2 = "0.5.3"
rand_core = { version = "0.6.4", features = ["std"] }
time = "0.3.34"
axum-extra = { version = "0.9.2", features = ["cookie"] }
[features]
default = []
+200
View File
@@ -0,0 +1,200 @@
# OxiCloud Authentication System
This document describes the authentication system for OxiCloud, a file storage system built with Rust and PostgreSQL.
## Overview
OxiCloud uses a standard JWT (JSON Web Token) authentication system with the following features:
- User registration and login
- Role-based access control (Admin/User)
- JWT token with refresh capabilities
- Secure password hashing with Argon2id
- User storage quotas
- File and folder ownership
## API Endpoints
The authentication API is available at the `/api/auth` endpoint:
- **POST /api/auth/register** - Register a new user
- **POST /api/auth/login** - Login and get tokens
- **POST /api/auth/refresh** - Refresh access token
- **GET /api/auth/me** - Get current user information
- **PUT /api/auth/change-password** - Change user password
- **POST /api/auth/logout** - Logout and invalidate refresh token
## Request/Response Examples
### Register
**Request:**
```json
POST /api/auth/register
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123"
}
```
**Response:**
```json
201 Created
{
"userId": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"username": "testuser",
"email": "test@example.com"
}
```
### Login
**Request:**
```json
POST /api/auth/login
{
"username": "testuser",
"password": "SecurePassword123"
}
```
**Response:**
```json
200 OK
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600
}
```
### Refresh Token
**Request:**
```json
POST /api/auth/refresh
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Response:**
```json
200 OK
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600
}
```
### Get Current User
**Request:**
```
GET /api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Response:**
```json
200 OK
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"username": "testuser",
"email": "test@example.com",
"role": "user",
"storageQuota": 10737418240,
"storageUsed": 1048576,
"createdAt": "2023-01-01T12:00:00Z"
}
```
### Change Password
**Request:**
```json
PUT /api/auth/change-password
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
{
"oldPassword": "SecurePassword123",
"newPassword": "NewSecurePassword456"
}
```
**Response:**
```
200 OK
```
### Logout
**Request:**
```
POST /api/auth/logout
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Response:**
```
200 OK
```
## Testing the Authentication System
1. Start PostgreSQL and create the database:
```bash
createdb oxicloud
psql -d oxicloud -f db/schema.sql
```
2. Set environment variables for authentication:
```bash
source test-auth-env.sh
```
3. Start the OxiCloud server:
```bash
cargo run
```
4. Run the authentication test script:
```bash
./test-auth-api.sh
```
## Database Schema
The authentication system uses the following tables:
- `users` - Store user information
- `sessions` - Store refresh token sessions
- `file_ownership` - Track file ownership
- `folder_ownership` - Track folder ownership
## Implementation Details
- **Password Hashing**: Argon2id with memory cost of 65536 (64MB), time cost of 3, and 4 parallelism
- **JWT Secret**: Configured via environment variable `OXICLOUD_JWT_SECRET`
- **Token Expiry**: Access token expires in 1 hour, refresh token in 30 days (configurable)
- **Database Connection**: PostgreSQL with connection pooling
- **Middleware**: Auth middleware for protected routes
## Security Considerations
- Passwords are never stored in plain text, only as Argon2id hashes
- JWT tokens are signed with a secret key
- Refresh tokens can be revoked to force logout
- Rate limiting should be implemented for login attempts
- Password policy requires at least 8 characters
- Regular security audits recommended
## Future Improvements
- Email verification for new registrations
- Password reset functionality
- Enhanced password policy
- Two-factor authentication
- OAuth integration for social logins
- Session management UI
+65
View File
@@ -0,0 +1,65 @@
-- OxiCloud Authentication Database Schema
-- Users table
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(32) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(10) NOT NULL CHECK (role IN ('admin', 'user')),
storage_quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10GB default
storage_used_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Sessions table for refresh tokens
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (user_id, refresh_token)
);
-- File ownership tracking
CREATE TABLE IF NOT EXISTS file_ownership (
file_id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path VARCHAR(1024) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, path)
);
-- Folder ownership tracking
CREATE TABLE IF NOT EXISTS folder_ownership (
folder_id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path VARCHAR(1024) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, path)
);
-- Create admin user (password: Admin123!)
INSERT INTO users (
id,
username,
email,
password_hash,
role,
storage_quota_bytes
) VALUES (
'00000000-0000-0000-0000-000000000000',
'admin',
'admin@oxicloud.local',
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$H3VxE8LL2qPT31DM3loTg6D+O4MSc2sD7GjlQ5h7Jkw', -- Admin123!
'admin',
107374182400 -- 100GB for admin
) ON CONFLICT (id) DO NOTHING;
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: oxicloud
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pg_data:
@@ -0,0 +1,62 @@
-- Create the auth schema
CREATE SCHEMA IF NOT EXISTS auth;
-- Create the users table
CREATE TABLE IF NOT EXISTS auth.users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(10) NOT NULL,
storage_quota_bytes BIGINT NOT NULL,
storage_used_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
last_login_at TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Create an index on username and email for fast lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON auth.users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email);
-- Create the sessions table
CREATE TABLE IF NOT EXISTS auth.sessions (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
refresh_token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
ip_address VARCHAR(45), -- to support IPv6
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE
);
-- Create indexes on user_id and refresh_token for fast lookups
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON auth.sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON auth.sessions(refresh_token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON auth.sessions(expires_at);
-- Create an index for getting active sessions
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked, expires_at)
WHERE NOT revoked AND expires_at > NOW();
-- Create the user_files table to track ownership of files
CREATE TABLE IF NOT EXISTS auth.user_files (
id SERIAL PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
file_id VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(user_id, file_path)
);
-- Create indexes for user_files
CREATE INDEX IF NOT EXISTS idx_user_files_user_id ON auth.user_files(user_id);
CREATE INDEX IF NOT EXISTS idx_user_files_file_id ON auth.user_files(file_id);
COMMENT ON TABLE auth.users IS 'Stores user account information';
COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens';
COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users';
+22
View File
@@ -62,3 +62,25 @@ impl From<FileDto> for File {
)
}
}
impl FileDto {
/// Creates an empty file DTO for stub implementations
pub fn empty() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-file".to_string(),
path: "/stub/path".to_string(),
size: 0,
mime_type: "application/octet-stream".to_string(),
folder_id: None,
created_at: 0,
modified_at: 0,
}
}
}
impl Default for FileDto {
fn default() -> Self {
Self::empty()
}
}
+21
View File
@@ -81,3 +81,24 @@ impl From<FolderDto> for Folder {
)
}
}
impl FolderDto {
/// Creates an empty folder DTO for stub implementations
pub fn empty() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-folder".to_string(),
path: "/stub/path".to_string(),
parent_id: None,
created_at: 0,
modified_at: 0,
is_root: true,
}
}
}
impl Default for FolderDto {
fn default() -> Self {
Self::empty()
}
}
+1
View File
@@ -2,4 +2,5 @@ pub mod file_dto;
pub mod folder_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod user_dto;
+67
View File
@@ -0,0 +1,67 @@
use serde::{Serialize, Deserialize};
use chrono::{DateTime, Utc};
use crate::domain::entities::user::{User, UserRole};
#[derive(Debug, Serialize, Deserialize)]
pub struct UserDto {
pub id: String,
pub username: String,
pub email: String,
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
}
impl From<User> for UserDto {
fn from(user: User) -> Self {
Self {
id: user.id().to_string(),
username: user.username().to_string(),
email: user.email().to_string(),
role: format!("{}", user.role()),
storage_quota_bytes: user.storage_quota_bytes(),
storage_used_bytes: user.storage_used_bytes(),
created_at: user.created_at(),
updated_at: user.updated_at(),
last_login_at: user.last_login_at(),
active: user.is_active(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginDto {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterDto {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthResponseDto {
pub user: UserDto,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChangePasswordDto {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshTokenDto {
pub refresh_token: String,
}
+46
View File
@@ -0,0 +1,46 @@
use async_trait::async_trait;
use crate::domain::entities::user::User;
use crate::domain::entities::session::Session;
use crate::common::errors::DomainError;
#[async_trait]
pub trait UserStoragePort: Send + Sync + 'static {
/// Crea un nuevo usuario
async fn create_user(&self, user: User) -> Result<User, DomainError>;
/// Obtiene un usuario por ID
async fn get_user_by_id(&self, id: &str) -> Result<User, DomainError>;
/// Obtiene un usuario por nombre de usuario
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
/// Obtiene un usuario por correo electrónico
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError>;
/// Actualiza un usuario existente
async fn update_user(&self, user: User) -> Result<User, DomainError>;
/// Actualiza solo el uso de almacenamiento de un usuario
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> Result<(), DomainError>;
/// Lista usuarios con paginación
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
/// Cambia la contraseña de un usuario
async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError>;
}
#[async_trait]
pub trait SessionStoragePort: Send + Sync + 'static {
/// Crea una nueva sesión
async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
/// Obtiene una sesión por token de actualización
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> Result<Session, DomainError>;
/// Revoca una sesión específica
async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>;
/// Revoca todas las sesiones de un usuario
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>;
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
}
/// Factory para crear implementaciones de casos de uso de archivos
pub trait FileUseCaseFactory {
pub trait FileUseCaseFactory: Send + Sync + 'static {
fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase>;
fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase>;
fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase>;
+1
View File
@@ -2,3 +2,4 @@ pub mod inbound;
pub mod outbound;
pub mod file_ports;
pub mod storage_ports;
pub mod auth_ports;
@@ -0,0 +1,275 @@
use std::sync::Arc;
use crate::domain::entities::user::{User, UserRole};
use crate::domain::entities::session::Session;
use crate::domain::services::auth_service::AuthService;
use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort};
use crate::application::dtos::user_dto::{UserDto, RegisterDto, LoginDto, AuthResponseDto, ChangePasswordDto, RefreshTokenDto};
use crate::common::errors::{DomainError, ErrorKind};
pub struct AuthApplicationService {
user_storage: Arc<dyn UserStoragePort>,
session_storage: Arc<dyn SessionStoragePort>,
auth_service: Arc<AuthService>,
}
impl AuthApplicationService {
pub fn new(
user_storage: Arc<dyn UserStoragePort>,
session_storage: Arc<dyn SessionStoragePort>,
auth_service: Arc<AuthService>,
) -> Self {
Self {
user_storage,
session_storage,
auth_service,
}
}
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
// Verificar usuario duplicado
if self.user_storage.get_user_by_username(&dto.username).await.is_ok() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("El usuario '{}' ya existe", dto.username)
));
}
if self.user_storage.get_user_by_email(&dto.email).await.is_ok() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("El email '{}' ya está registrado", dto.email)
));
}
// Cuota predeterminada: 1GB (ajustable según plan)
let default_quota = 1024 * 1024 * 1024; // 1GB
// Crear usuario
let user = User::new(
dto.username,
dto.email,
dto.password,
UserRole::User, // Por defecto: usuario normal
default_quota,
).map_err(|e| DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Error al crear usuario: {}", e)
))?;
// Guardar usuario
let created_user = self.user_storage.create_user(user).await?;
tracing::info!("Usuario registrado: {}", created_user.id());
Ok(UserDto::from(created_user))
}
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
// Buscar usuario
let mut user = self.user_storage
.get_user_by_username(&dto.username)
.await
.map_err(|_| DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Credenciales inválidas"
))?;
// Verificar si usuario está activo
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Cuenta desactivada"
));
}
// Verificar contraseña
let is_valid = user.verify_password(&dto.password)
.map_err(|_| DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Credenciales inválidas"
))?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Credenciales inválidas"
));
}
// Actualizar último login
user.register_login();
self.user_storage.update_user(user.clone()).await?;
// Generar tokens
let access_token = self.auth_service.generate_access_token(&user)
.map_err(DomainError::from)?;
let refresh_token = self.auth_service.generate_refresh_token();
// Guardar sesión
let session = Session::new(
user.id().to_string(),
refresh_token.clone(),
None, // IP (se puede añadir desde la capa HTTP)
None, // User-Agent (se puede añadir desde la capa HTTP)
self.auth_service.refresh_token_expiry_days(),
);
self.session_storage.create_session(session).await?;
// Respuesta de autenticación
Ok(AuthResponseDto {
user: UserDto::from(user),
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: self.auth_service.refresh_token_expiry_secs(),
})
}
pub async fn refresh_token(&self, dto: RefreshTokenDto) -> Result<AuthResponseDto, DomainError> {
// Obtener sesión válida
let session = self.session_storage
.get_session_by_refresh_token(&dto.refresh_token)
.await?;
// Verificar si la sesión está expirada o revocada
if session.is_expired() || session.is_revoked() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Sesión expirada o inválida"
));
}
// Obtener usuario
let user = self.user_storage
.get_user_by_id(session.user_id())
.await?;
// Verificar si usuario está activo
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Cuenta desactivada"
));
}
// Revocar sesión actual
self.session_storage.revoke_session(session.id()).await?;
// Generar nuevos tokens
let access_token = self.auth_service.generate_access_token(&user)
.map_err(DomainError::from)?;
let new_refresh_token = self.auth_service.generate_refresh_token();
// Crear nueva sesión
let new_session = Session::new(
user.id().to_string(),
new_refresh_token.clone(),
None,
None,
self.auth_service.refresh_token_expiry_days(),
);
self.session_storage.create_session(new_session).await?;
Ok(AuthResponseDto {
user: UserDto::from(user),
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: self.auth_service.refresh_token_expiry_secs(),
})
}
pub async fn logout(&self, user_id: &str, refresh_token: &str) -> Result<(), DomainError> {
// Obtener sesión
let session = match self.session_storage.get_session_by_refresh_token(refresh_token).await {
Ok(s) => s,
// Si la sesión no existe, consideramos el logout como exitoso
Err(_) => return Ok(()),
};
// Verificar que la sesión pertenece al usuario
if session.user_id() != user_id {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"La sesión no pertenece al usuario"
));
}
// Revocar sesión
self.session_storage.revoke_session(session.id()).await?;
Ok(())
}
pub async fn logout_all(&self, user_id: &str) -> Result<u64, DomainError> {
// Revocar todas las sesiones del usuario
let revoked_count = self.session_storage.revoke_all_user_sessions(user_id).await?;
Ok(revoked_count)
}
pub async fn change_password(&self, user_id: &str, dto: ChangePasswordDto) -> Result<(), DomainError> {
// Obtener usuario
let mut user = self.user_storage.get_user_by_id(user_id).await?;
// Verificar contraseña actual
let is_valid = user.verify_password(&dto.current_password)
.map_err(|_| DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Contraseña actual incorrecta"
))?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Contraseña actual incorrecta"
));
}
// Actualizar contraseña
user.update_password(dto.new_password.clone())
.map_err(|e| DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Error al cambiar contraseña: {}", e)
))?;
// Guardar usuario actualizado
self.user_storage.update_user(user).await?;
// Opcional: revocar todas las sesiones para forzar re-login con nueva contraseña
self.session_storage.revoke_all_user_sessions(user_id).await?;
Ok(())
}
pub async fn get_user(&self, user_id: &str) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
}
// Alias for consistency with handler method
pub async fn get_user_by_id(&self, user_id: &str) -> Result<UserDto, DomainError> {
self.get_user(user_id).await
}
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
}
@@ -16,6 +16,13 @@ impl FileManagementService {
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
Self { file_repository }
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
file_repository: Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub())
}
}
}
#[async_trait]
@@ -18,6 +18,13 @@ impl FileRetrievalService {
pub fn new(file_repository: Arc<dyn FileReadPort>) -> Self {
Self { file_repository }
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
file_repository: Arc::new(crate::infrastructure::repositories::FileFsReadRepository::default_stub())
}
}
}
#[async_trait]
+45
View File
@@ -79,6 +79,51 @@ impl FileService {
Self { file_repository }
}
/// Creates a stub implementation for testing and middleware
pub fn new_stub() -> impl FileUseCase {
struct FileServiceStub;
#[async_trait]
impl FileUseCase for FileServiceStub {
async fn upload_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn get_file(&self, _id: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
Ok(vec![])
}
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
let empty_stream = futures::stream::empty();
Ok(Box::new(empty_stream))
}
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
}
FileServiceStub
}
/// Uploads a new file from bytes
pub async fn upload_file_from_bytes(
&self,
@@ -16,6 +16,13 @@ impl FileUploadService {
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
Self { file_repository }
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
file_repository: Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub())
}
}
}
#[async_trait]
@@ -23,6 +23,14 @@ impl AppFileUseCaseFactory {
file_write_repository,
}
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
file_read_repository: Arc::new(crate::infrastructure::repositories::FileFsReadRepository::default_stub()),
file_write_repository: Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub()),
}
}
}
impl FileUseCaseFactory for AppFileUseCaseFactory {
@@ -17,6 +17,57 @@ impl FolderService {
pub fn new(folder_storage: Arc<dyn FolderStoragePort>) -> Self {
Self { folder_storage }
}
/// Creates a stub implementation for testing and middleware
pub fn new_stub() -> impl FolderUseCase {
struct FolderServiceStub;
#[async_trait]
impl FolderUseCase for FolderServiceStub {
async fn create_folder(&self, _dto: CreateFolderDto) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn get_folder(&self, _id: &str) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError> {
Ok(vec![])
}
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_pagination: &crate::application::dtos::pagination::PaginationRequestDto
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError> {
Ok(crate::application::dtos::pagination::PaginatedResponseDto::new(
vec![],
0,
10,
0
))
}
async fn rename_folder(&self, _id: &str, _dto: RenameFolderDto) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
Ok(())
}
}
FolderServiceStub
}
}
#[async_trait]
+1
View File
@@ -9,6 +9,7 @@ pub mod file_upload_service;
pub mod file_retrieval_service;
pub mod file_management_service;
pub mod file_use_case_factory;
pub mod auth_application_service;
// Re-exportar para facilitar acceso
pub use file_upload_service::FileUploadService;
+34
View File
@@ -0,0 +1,34 @@
use std::sync::Arc;
use anyhow::Result;
use sqlx::PgPool;
use crate::domain::services::auth_service::AuthService;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::infrastructure::repositories::{UserPgRepository, SessionPgRepository};
use crate::common::config::AppConfig;
use crate::common::di::AuthServices;
pub async fn create_auth_services(config: &AppConfig, pool: Arc<PgPool>) -> Result<AuthServices> {
// Crear servicio de dominio de autenticación
let auth_service = Arc::new(AuthService::new(
config.auth.jwt_secret.clone(),
config.auth.access_token_expiry_secs,
config.auth.refresh_token_expiry_secs,
));
// Crear repositorios PostgreSQL
let user_repository = Arc::new(UserPgRepository::new(pool.clone()));
let session_repository = Arc::new(SessionPgRepository::new(pool.clone()));
// Crear servicio de aplicación de autenticación
let auth_application_service = Arc::new(AuthApplicationService::new(
user_repository,
session_repository,
auth_service.clone(),
));
Ok(AuthServices {
auth_service,
auth_application_service,
})
}
+195
View File
@@ -1,4 +1,6 @@
use std::time::Duration;
use std::path::PathBuf;
use std::env;
/// Configuración de caché
#[derive(Debug, Clone)]
@@ -52,6 +54,21 @@ impl TimeoutConfig {
Duration::from_millis(self.file_operation_ms)
}
/// Obtiene un Duration para operaciones de escritura de archivo
pub fn file_write_timeout(&self) -> Duration {
Duration::from_millis(self.file_operation_ms)
}
/// Obtiene un Duration para operaciones de lectura de archivo
pub fn file_read_timeout(&self) -> Duration {
Duration::from_millis(self.file_operation_ms)
}
/// Obtiene un Duration para operaciones de eliminación de archivo
pub fn file_delete_timeout(&self) -> Duration {
Duration::from_millis(self.file_operation_ms)
}
/// Obtiene un Duration para operaciones de directorio
pub fn dir_timeout(&self) -> Duration {
Duration::from_millis(self.dir_operation_ms)
@@ -178,9 +195,81 @@ impl Default for ConcurrencyConfig {
}
}
/// Configuración de base de datos
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub connection_string: String,
pub max_connections: u32,
pub min_connections: u32,
pub connect_timeout_secs: u64,
pub idle_timeout_secs: u64,
pub max_lifetime_secs: u64,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
connection_string: "postgres://postgres:postgres@localhost/oxicloud".to_string(),
max_connections: 20,
min_connections: 5,
connect_timeout_secs: 10,
idle_timeout_secs: 300,
max_lifetime_secs: 1800,
}
}
}
/// Configuración de autenticación
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub jwt_secret: String,
pub access_token_expiry_secs: i64,
pub refresh_token_expiry_secs: i64,
pub hash_memory_cost: u32,
pub hash_time_cost: u32,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
jwt_secret: "ox1cl0ud-sup3r-s3cr3t-k3y-f0r-t0k3n-s1gn1ng".to_string(),
access_token_expiry_secs: 3600, // 1 hora
refresh_token_expiry_secs: 2592000, // 30 días
hash_memory_cost: 65536, // 64MB
hash_time_cost: 3,
}
}
}
/// Configuración de funcionalidades (feature flags)
#[derive(Debug, Clone)]
pub struct FeaturesConfig {
pub enable_auth: bool,
pub enable_user_storage_quotas: bool,
pub enable_file_sharing: bool,
}
impl Default for FeaturesConfig {
fn default() -> Self {
Self {
enable_auth: false,
enable_user_storage_quotas: false,
enable_file_sharing: false,
}
}
}
/// Configuración global de la aplicación
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Ruta del directorio de almacenamiento
pub storage_path: PathBuf,
/// Ruta del directorio de archivos estáticos
pub static_path: PathBuf,
/// Puerto del servidor
pub server_port: u16,
/// Host del servidor
pub server_host: String,
/// Configuración de caché
pub cache: CacheConfig,
/// Configuración de timeouts
@@ -189,19 +278,125 @@ pub struct AppConfig {
pub resources: ResourceConfig,
/// Configuración de concurrencia
pub concurrency: ConcurrencyConfig,
/// Configuración de base de datos
pub database: DatabaseConfig,
/// Configuración de autenticación
pub auth: AuthConfig,
/// Configuración de funcionalidades
pub features: FeaturesConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
storage_path: PathBuf::from("./storage"),
static_path: PathBuf::from("./static"),
server_port: 8085,
server_host: "127.0.0.1".to_string(),
cache: CacheConfig::default(),
timeouts: TimeoutConfig::default(),
resources: ResourceConfig::default(),
concurrency: ConcurrencyConfig::default(),
database: DatabaseConfig::default(),
auth: AuthConfig::default(),
features: FeaturesConfig::default(),
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
// Usar variables de entorno para sobrescribir valores por defecto
if let Ok(storage_path) = env::var("OXICLOUD_STORAGE_PATH") {
config.storage_path = PathBuf::from(storage_path);
}
if let Ok(static_path) = env::var("OXICLOUD_STATIC_PATH") {
config.static_path = PathBuf::from(static_path);
}
if let Ok(server_port) = env::var("OXICLOUD_SERVER_PORT") {
if let Ok(port) = server_port.parse::<u16>() {
config.server_port = port;
}
}
if let Ok(server_host) = env::var("OXICLOUD_SERVER_HOST") {
config.server_host = server_host;
}
// Configuración de Database
if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") {
config.database.connection_string = connection_string;
}
if let Ok(max_connections) = env::var("OXICLOUD_DB_MAX_CONNECTIONS")
.map(|v| v.parse::<u32>()) {
if let Ok(val) = max_connections {
config.database.max_connections = val;
}
}
if let Ok(min_connections) = env::var("OXICLOUD_DB_MIN_CONNECTIONS")
.map(|v| v.parse::<u32>()) {
if let Ok(val) = min_connections {
config.database.min_connections = val;
}
}
// Configuración Auth
if let Ok(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") {
config.auth.jwt_secret = jwt_secret;
}
if let Ok(access_token_expiry) = env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS")
.map(|v| v.parse::<i64>()) {
if let Ok(val) = access_token_expiry {
config.auth.access_token_expiry_secs = val;
}
}
if let Ok(refresh_token_expiry) = env::var("OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS")
.map(|v| v.parse::<i64>()) {
if let Ok(val) = refresh_token_expiry {
config.auth.refresh_token_expiry_secs = val;
}
}
// Feature flags
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH")
.map(|v| v.parse::<bool>()) {
if let Ok(val) = enable_auth {
config.features.enable_auth = val;
}
}
if let Ok(enable_user_storage_quotas) = env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS")
.map(|v| v.parse::<bool>()) {
if let Ok(val) = enable_user_storage_quotas {
config.features.enable_user_storage_quotas = val;
}
}
config
}
pub fn with_features(mut self, features: FeaturesConfig) -> Self {
self.features = features;
self
}
pub fn db_enabled(&self) -> bool {
self.features.enable_auth
}
pub fn auth_enabled(&self) -> bool {
self.features.enable_auth
}
}
/// Obtenemos una configuración global por defecto
#[allow(dead_code)]
pub fn default_config() -> AppConfig {
+24
View File
@@ -0,0 +1,24 @@
use sqlx::{postgres::PgPoolOptions, PgPool};
use anyhow::Result;
use std::time::Duration;
use crate::common::config::AppConfig;
pub async fn create_database_pool(config: &AppConfig) -> Result<PgPool> {
tracing::info!("Inicializando conexión a PostgreSQL...");
// Crear el pool de conexiones con las opciones de configuración
let pool = PgPoolOptions::new()
.max_connections(config.database.max_connections)
.min_connections(config.database.min_connections)
.acquire_timeout(Duration::from_secs(config.database.connect_timeout_secs))
.idle_timeout(Duration::from_secs(config.database.idle_timeout_secs))
.max_lifetime(Duration::from_secs(config.database.max_lifetime_secs))
.connect(&config.database.connection_string)
.await?;
// Verificar la conexión
sqlx::query("SELECT 1").execute(&pool).await?;
tracing::info!("Conexión a PostgreSQL establecida correctamente");
Ok(pool)
}
+517 -1
View File
@@ -1,6 +1,10 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use sqlx::PgPool;
use crate::domain::services::auth_service::AuthService;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::domain::services::path_service::PathService;
use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository;
@@ -224,7 +228,7 @@ impl AppServiceFactory {
pub struct CoreServices {
pub path_service: Arc<PathService>,
pub cache_manager: Arc<StorageCacheManager>,
pub id_mapping_service: Arc<IdMappingService>,
pub id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
pub config: AppConfig,
}
@@ -252,3 +256,515 @@ pub struct ApplicationServices {
pub file_use_case_factory: Arc<dyn FileUseCaseFactory>,
pub i18n_service: Arc<I18nApplicationService>,
}
/// Contenedor para servicios de autenticación
#[allow(dead_code)]
pub struct AuthServices {
pub auth_service: Arc<AuthService>,
pub auth_application_service: Arc<AuthApplicationService>,
}
/// Estado global de la aplicación para dependency injection
pub struct AppState {
pub core: CoreServices,
pub repositories: RepositoryServices,
pub applications: ApplicationServices,
pub db_pool: Option<Arc<PgPool>>,
pub auth_service: Option<AuthServices>,
}
impl Default for AppState {
fn default() -> Self {
// This is just a minimal stub version for auth middleware
// We'll need to create proper instance in main.rs
let config = crate::common::config::AppConfig::default();
let path_service = Arc::new(
crate::domain::services::path_service::PathService::new(
std::path::PathBuf::from("./storage")
)
);
// Create stub service implementations
struct DummyIdMappingService;
#[async_trait::async_trait]
impl crate::application::ports::outbound::IdMappingPort for DummyIdMappingService {
async fn get_or_create_id(&self, _path: &crate::domain::services::path_service::StoragePath) -> Result<String, crate::common::errors::DomainError> {
Ok("dummy-id".to_string())
}
async fn get_path_by_id(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, crate::common::errors::DomainError> {
Ok(crate::domain::services::path_service::StoragePath::from_string("/"))
}
async fn update_path(&self, _id: &str, _new_path: &crate::domain::services::path_service::StoragePath) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
async fn remove_id(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
async fn save_changes(&self) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
}
struct DummyStorageMediator;
#[async_trait::async_trait]
impl crate::application::services::storage_mediator::StorageMediator for DummyStorageMediator {
async fn get_folder_path(&self, _folder_id: &str) -> Result<std::path::PathBuf, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(std::path::PathBuf::from("/tmp"))
}
async fn get_folder_storage_path(&self, _folder_id: &str) -> Result<crate::domain::services::path_service::StoragePath, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(crate::domain::services::path_service::StoragePath::root())
}
async fn get_folder(&self, _folder_id: &str) -> Result<crate::domain::entities::folder::Folder, crate::application::services::storage_mediator::StorageMediatorError> {
Err(crate::application::services::storage_mediator::StorageMediatorError::NotFound("Stub not implemented".to_string()))
}
async fn file_exists_at_path(&self, _path: &std::path::Path) -> Result<bool, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(false)
}
async fn file_exists_at_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(false)
}
async fn folder_exists_at_path(&self, _path: &std::path::Path) -> Result<bool, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(false)
}
async fn folder_exists_at_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, crate::application::services::storage_mediator::StorageMediatorError> {
Ok(false)
}
fn resolve_path(&self, _relative_path: &std::path::Path) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp")
}
fn resolve_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp")
}
async fn ensure_directory(&self, _path: &std::path::Path) -> Result<(), crate::application::services::storage_mediator::StorageMediatorError> {
Ok(())
}
async fn ensure_storage_directory(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<(), crate::application::services::storage_mediator::StorageMediatorError> {
Ok(())
}
}
struct DummyFileReadPort;
#[async_trait::async_trait]
impl crate::application::ports::storage_ports::FileReadPort for DummyFileReadPort {
async fn get_file(&self, _id: &str) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, crate::common::errors::DomainError> {
let empty_stream = futures::stream::empty::<Result<bytes::Bytes, std::io::Error>>();
Ok(Box::new(empty_stream))
}
}
struct DummyFileWritePort;
#[async_trait::async_trait]
impl crate::application::ports::storage_ports::FileWritePort for DummyFileWritePort {
async fn save_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn move_file(&self, _file_id: &str, _target_folder_id: Option<String>) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
}
struct DummyFileStoragePort;
#[async_trait::async_trait]
impl crate::application::ports::outbound::FileStoragePort for DummyFileStoragePort {
async fn save_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn get_file(&self, _id: &str) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, crate::common::errors::DomainError> {
let empty_stream = futures::stream::empty::<Result<bytes::Bytes, std::io::Error>>();
Ok(Box::new(empty_stream))
}
async fn move_file(&self, _file_id: &str, _target_folder_id: Option<String>) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
Ok(crate::domain::entities::file::File::default())
}
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, crate::common::errors::DomainError> {
Ok(crate::domain::services::path_service::StoragePath::from_string("/"))
}
}
struct DummyFolderStoragePort;
#[async_trait::async_trait]
impl crate::application::ports::outbound::FolderStoragePort for DummyFolderStoragePort {
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, crate::common::errors::DomainError> {
Ok(crate::domain::entities::folder::Folder::default())
}
async fn get_folder(&self, _id: &str) -> Result<crate::domain::entities::folder::Folder, crate::common::errors::DomainError> {
Ok(crate::domain::entities::folder::Folder::default())
}
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, crate::common::errors::DomainError> {
Ok(crate::domain::entities::folder::Folder::default())
}
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_offset: usize,
_limit: usize,
_include_total: bool
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), crate::common::errors::DomainError> {
Ok((Vec::new(), Some(0)))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, crate::common::errors::DomainError> {
Ok(crate::domain::entities::folder::Folder::default())
}
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, crate::common::errors::DomainError> {
Ok(crate::domain::entities::folder::Folder::default())
}
async fn delete_folder(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, crate::common::errors::DomainError> {
Ok(false)
}
async fn get_folder_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, crate::common::errors::DomainError> {
Ok(crate::domain::services::path_service::StoragePath::from_string("/"))
}
}
struct DummyFilePathResolutionPort;
#[async_trait::async_trait]
impl crate::application::ports::storage_ports::FilePathResolutionPort for DummyFilePathResolutionPort {
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, crate::common::errors::DomainError> {
Ok(crate::domain::services::path_service::StoragePath::from_string("/"))
}
fn resolve_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> std::path::PathBuf {
std::path::PathBuf::from("/")
}
}
struct DummyI18nService;
#[async_trait::async_trait]
impl crate::domain::services::i18n_service::I18nService for DummyI18nService {
async fn translate(&self, _key: &str, _locale: crate::domain::services::i18n_service::Locale) -> crate::domain::services::i18n_service::I18nResult<String> {
Ok(String::new())
}
async fn load_translations(&self, _locale: crate::domain::services::i18n_service::Locale) -> crate::domain::services::i18n_service::I18nResult<()> {
Ok(())
}
async fn available_locales(&self) -> Vec<crate::domain::services::i18n_service::Locale> {
vec![crate::domain::services::i18n_service::Locale::default()]
}
async fn is_supported(&self, _locale: crate::domain::services::i18n_service::Locale) -> bool {
true
}
}
struct DummyFolderUseCase;
#[async_trait::async_trait]
impl crate::application::ports::inbound::FolderUseCase for DummyFolderUseCase {
async fn create_folder(&self, _dto: crate::application::dtos::folder_dto::CreateFolderDto) -> Result<crate::application::dtos::folder_dto::FolderDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::folder_dto::FolderDto::default())
}
async fn get_folder(&self, _id: &str) -> Result<crate::application::dtos::folder_dto::FolderDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::folder_dto::FolderDto::default())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<crate::application::dtos::folder_dto::FolderDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::folder_dto::FolderDto::default())
}
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::application::dtos::folder_dto::FolderDto>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_pagination: &crate::application::dtos::pagination::PaginationRequestDto
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<crate::application::dtos::folder_dto::FolderDto>, crate::common::errors::DomainError> {
Ok(crate::application::dtos::pagination::PaginatedResponseDto::new(
Vec::new(),
0,
10,
0
))
}
async fn rename_folder(&self, _id: &str, _dto: crate::application::dtos::folder_dto::RenameFolderDto) -> Result<crate::application::dtos::folder_dto::FolderDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::folder_dto::FolderDto::default())
}
async fn move_folder(&self, _id: &str, _dto: crate::application::dtos::folder_dto::MoveFolderDto) -> Result<crate::application::dtos::folder_dto::FolderDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::folder_dto::FolderDto::default())
}
async fn delete_folder(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
}
struct DummyFileUseCase;
#[async_trait::async_trait]
impl crate::application::ports::inbound::FileUseCase for DummyFileUseCase {
async fn upload_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
async fn get_file(&self, _id: &str) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::application::dtos::file_dto::FileDto>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, crate::common::errors::DomainError> {
// Create an empty stream
let empty_stream = futures::stream::empty::<Result<bytes::Bytes, std::io::Error>>();
Ok(Box::new(empty_stream))
}
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
}
struct DummyFileUploadUseCase;
#[async_trait::async_trait]
impl crate::application::ports::file_ports::FileUploadUseCase for DummyFileUploadUseCase {
async fn upload_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
}
struct DummyFileRetrievalUseCase;
#[async_trait::async_trait]
impl crate::application::ports::file_ports::FileRetrievalUseCase for DummyFileRetrievalUseCase {
async fn get_file(&self, _id: &str) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::application::dtos::file_dto::FileDto>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, crate::common::errors::DomainError> {
Ok(Vec::new())
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, crate::common::errors::DomainError> {
// Create an empty stream
let empty_stream = futures::stream::empty::<Result<bytes::Bytes, std::io::Error>>();
Ok(Box::new(empty_stream))
}
}
struct DummyFileManagementUseCase;
#[async_trait::async_trait]
impl crate::application::ports::file_ports::FileManagementUseCase for DummyFileManagementUseCase {
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<crate::application::dtos::file_dto::FileDto, crate::common::errors::DomainError> {
Ok(crate::application::dtos::file_dto::FileDto::default())
}
async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> {
Ok(())
}
}
struct DummyFileUseCaseFactory;
impl crate::application::ports::file_ports::FileUseCaseFactory for DummyFileUseCaseFactory {
fn create_file_upload_use_case(&self) -> std::sync::Arc<dyn crate::application::ports::file_ports::FileUploadUseCase> {
std::sync::Arc::new(DummyFileUploadUseCase)
}
fn create_file_retrieval_use_case(&self) -> std::sync::Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase> {
std::sync::Arc::new(DummyFileRetrievalUseCase)
}
fn create_file_management_use_case(&self) -> std::sync::Arc<dyn crate::application::ports::file_ports::FileManagementUseCase> {
std::sync::Arc::new(DummyFileManagementUseCase)
}
}
struct DummyI18nApplicationService {};
// Need to implement the actual service to match the type signature in DI container
impl DummyI18nApplicationService {
fn dummy() -> crate::application::services::i18n_application_service::I18nApplicationService {
// We need to create an actual I18nApplicationService
crate::application::services::i18n_application_service::I18nApplicationService::new(
Arc::new(DummyI18nService) as Arc<dyn crate::domain::services::i18n_service::I18nService>
)
}
}
// Create service instances
let id_mapping_service = Arc::new(DummyIdMappingService) as Arc<dyn crate::application::ports::outbound::IdMappingPort>;
let storage_mediator = Arc::new(DummyStorageMediator) as Arc<dyn crate::application::services::storage_mediator::StorageMediator>;
let i18n_repository = Arc::new(DummyI18nService) as Arc<dyn crate::domain::services::i18n_service::I18nService>;
let folder_service = Arc::new(DummyFolderUseCase) as Arc<dyn crate::application::ports::inbound::FolderUseCase>;
let file_service = Arc::new(DummyFileUseCase) as Arc<dyn crate::application::ports::inbound::FileUseCase>;
let file_upload_service = Arc::new(DummyFileUploadUseCase) as Arc<dyn crate::application::ports::file_ports::FileUploadUseCase>;
let file_retrieval_service = Arc::new(DummyFileRetrievalUseCase) as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>;
let file_management_service = Arc::new(DummyFileManagementUseCase) as Arc<dyn crate::application::ports::file_ports::FileManagementUseCase>;
let file_use_case_factory = Arc::new(DummyFileUseCaseFactory) as Arc<dyn crate::application::ports::file_ports::FileUseCaseFactory>;
// This creates the core services needed for basic functionality
let core_services = CoreServices {
path_service: path_service.clone(),
cache_manager: Arc::new(crate::infrastructure::services::cache_manager::StorageCacheManager::default()),
id_mapping_service: id_mapping_service.clone(),
config: config.clone(),
};
// Create empty repository implementations
let repository_services = RepositoryServices {
folder_repository: Arc::new(DummyFolderStoragePort) as Arc<dyn crate::application::ports::outbound::FolderStoragePort>,
file_repository: Arc::new(DummyFileStoragePort) as Arc<dyn crate::application::ports::outbound::FileStoragePort>,
file_read_repository: Arc::new(DummyFileReadPort) as Arc<dyn crate::application::ports::storage_ports::FileReadPort>,
file_write_repository: Arc::new(DummyFileWritePort) as Arc<dyn crate::application::ports::storage_ports::FileWritePort>,
i18n_repository,
storage_mediator: storage_mediator.clone(),
metadata_manager: Arc::new(crate::infrastructure::repositories::FileMetadataManager::default()),
path_resolver: Arc::new(crate::infrastructure::repositories::file_path_resolver::FilePathResolver::new(
path_service.clone(),
storage_mediator.clone(),
id_mapping_service.clone()
)),
};
// Create application services
let application_services = ApplicationServices {
folder_service,
file_service,
file_upload_service,
file_retrieval_service,
file_management_service,
file_use_case_factory,
i18n_service: Arc::new(DummyI18nApplicationService::dummy()),
};
// Return a minimal app state
Self {
core: core_services,
repositories: repository_services,
applications: application_services,
db_pool: None,
auth_service: None,
}
}
}
impl AppState {
pub fn new(
core: CoreServices,
repositories: RepositoryServices,
applications: ApplicationServices,
) -> Self {
Self {
core,
repositories,
applications,
db_pool: None,
auth_service: None,
}
}
pub fn with_database(mut self, db_pool: Arc<PgPool>) -> Self {
self.db_pool = Some(db_pool);
self
}
pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self {
self.auth_service = Some(auth_services);
self
}
}
+80
View File
@@ -223,3 +223,83 @@ macro_rules! impl_from_error {
// Implementación para errores estándar comunes
impl_from_error!(std::io::Error, "IO");
impl_from_error!(serde_json::Error, "Serialization");
// Error para capas HTTP/API
#[derive(Debug)]
pub struct AppError {
pub status_code: axum::http::StatusCode,
pub message: String,
pub error_type: String,
}
// Estructura de respuesta de error
#[derive(serde::Serialize)]
pub struct ErrorResponse {
pub status: String,
pub message: String,
pub error_type: String,
}
impl AppError {
pub fn new(status_code: axum::http::StatusCode, message: impl Into<String>, error_type: impl Into<String>) -> Self {
Self {
status_code,
message: message.into(),
error_type: error_type.into(),
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::BAD_REQUEST, message, "BadRequest")
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::UNAUTHORIZED, message, "Unauthorized")
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::FORBIDDEN, message, "Forbidden")
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::NOT_FOUND, message, "NotFound")
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, message, "InternalError")
}
}
impl From<DomainError> for AppError {
fn from(err: DomainError) -> Self {
let status_code = match err.kind {
ErrorKind::NotFound => axum::http::StatusCode::NOT_FOUND,
ErrorKind::AlreadyExists => axum::http::StatusCode::CONFLICT,
ErrorKind::InvalidInput => axum::http::StatusCode::BAD_REQUEST,
ErrorKind::AccessDenied => axum::http::StatusCode::FORBIDDEN,
ErrorKind::Timeout => axum::http::StatusCode::REQUEST_TIMEOUT,
ErrorKind::InternalError => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
ErrorKind::NotImplemented => axum::http::StatusCode::NOT_IMPLEMENTED,
};
Self {
status_code,
message: err.message,
error_type: err.kind.to_string(),
}
}
}
impl axum::response::IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let status = self.status_code;
let error_response = ErrorResponse {
status: status.to_string(),
message: self.message,
error_type: self.error_type,
};
let body = axum::Json(error_response);
(status, body).into_response()
}
}
+2
View File
@@ -2,3 +2,5 @@ pub mod errors;
pub mod config;
pub mod cache;
pub mod di;
pub mod db;
pub mod auth_factory;
+16
View File
@@ -50,6 +50,22 @@ pub struct File {
// Ya no necesitamos este módulo, ahora usamos un String directamente
impl Default for File {
fn default() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-file.txt".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
size: 0,
mime_type: "application/octet-stream".to_string(),
folder_id: None,
created_at: 0,
modified_at: 0,
}
}
}
impl File {
/// Crea un nuevo archivo con validación
pub fn new(
+14
View File
@@ -44,6 +44,20 @@ pub struct Folder {
// Ya no necesitamos este módulo, ahora usamos un String directamente
impl Default for Folder {
fn default() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-folder".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
parent_id: None,
created_at: 0,
modified_at: 0,
}
}
}
impl Folder {
/// Creates a new folder with validation
pub fn new(
+2
View File
@@ -1,3 +1,5 @@
pub mod file;
pub mod folder;
pub mod user;
pub mod session;
+70
View File
@@ -0,0 +1,70 @@
use serde::{Serialize, Deserialize};
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub user_id: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: DateTime<Utc>,
pub revoked: bool,
}
impl Session {
pub fn new(
user_id: String,
refresh_token: String,
ip_address: Option<String>,
user_agent: Option<String>,
expires_in_days: i64,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id,
refresh_token,
expires_at: now + Duration::days(expires_in_days),
ip_address,
user_agent,
created_at: now,
revoked: false,
}
}
// Getters
pub fn id(&self) -> &str {
&self.id
}
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn refresh_token(&self) -> &str {
&self.refresh_token
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_revoked(&self) -> bool {
self.revoked
}
pub fn revoke(&mut self) {
self.revoked = true;
}
}
+232
View File
@@ -0,0 +1,232 @@
use serde::{Serialize, Deserialize};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use rand_core::OsRng;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("Username inválido: {0}")]
InvalidUsername(String),
#[error("Password inválido: {0}")]
InvalidPassword(String),
#[error("Error en la validación: {0}")]
ValidationError(String),
#[error("Error en la autenticación: {0}")]
AuthenticationError(String),
}
pub type UserResult<T> = Result<T, UserError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum UserRole {
Admin,
User,
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
UserRole::Admin => write!(f, "admin"),
UserRole::User => write!(f, "user"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
id: String,
username: String,
email: String,
#[serde(skip_serializing)]
password_hash: String,
role: UserRole,
storage_quota_bytes: i64,
storage_used_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
}
impl User {
pub fn new(
username: String,
email: String,
password: String,
role: UserRole,
storage_quota_bytes: i64,
) -> UserResult<Self> {
// Validaciones
if username.is_empty() || username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(format!(
"Username debe tener entre 3 y 32 caracteres"
)));
}
if !email.contains('@') || email.len() < 5 {
return Err(UserError::ValidationError(format!(
"Email inválido"
)));
}
if password.len() < 8 {
return Err(UserError::InvalidPassword(format!(
"Password debe tener al menos 8 caracteres"
)));
}
// Generar hash con Argon2id (recomendado para 2023+)
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)
.map_err(|e| UserError::ValidationError(format!("Error al generar hash: {}", e)))?
.to_string();
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4().to_string(),
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes: 0,
created_at: now,
updated_at: now,
last_login_at: None,
active: true,
})
}
// Crear desde valores existentes (para reconstrucción desde BD)
pub fn from_data(
id: String,
username: String,
email: String,
password_hash: String,
role: UserRole,
storage_quota_bytes: i64,
storage_used_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
) -> Self {
Self {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
}
}
// Getters
pub fn id(&self) -> &str {
&self.id
}
pub fn username(&self) -> &str {
&self.username
}
pub fn email(&self) -> &str {
&self.email
}
pub fn role(&self) -> UserRole {
self.role
}
pub fn storage_quota_bytes(&self) -> i64 {
self.storage_quota_bytes
}
pub fn storage_used_bytes(&self) -> i64 {
self.storage_used_bytes
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
pub fn last_login_at(&self) -> Option<DateTime<Utc>> {
self.last_login_at
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn password_hash(&self) -> &str {
&self.password_hash
}
// Verificación de password
pub fn verify_password(&self, password: &str) -> UserResult<bool> {
let parsed_hash = PasswordHash::new(&self.password_hash)
.map_err(|e| UserError::AuthenticationError(format!("Error al procesar hash: {}", e)))?;
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
}
// Cambiar contraseña
pub fn update_password(&mut self, new_password: String) -> UserResult<()> {
if new_password.len() < 8 {
return Err(UserError::InvalidPassword(format!(
"Password debe tener al menos 8 caracteres"
)));
}
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
self.password_hash = argon2.hash_password(new_password.as_bytes(), &salt)
.map_err(|e| UserError::ValidationError(format!("Error al generar hash: {}", e)))?
.to_string();
self.updated_at = Utc::now();
Ok(())
}
// Actualizar uso de almacenamiento
pub fn update_storage_used(&mut self, storage_used_bytes: i64) {
self.storage_used_bytes = storage_used_bytes;
self.updated_at = Utc::now();
}
// Registrar login
pub fn register_login(&mut self) {
let now = Utc::now();
self.last_login_at = Some(now);
self.updated_at = now;
}
// Desactivar usuario
pub fn deactivate(&mut self) {
self.active = false;
self.updated_at = Utc::now();
}
// Activar usuario
pub fn activate(&mut self) {
self.active = true;
self.updated_at = Utc::now();
}
}
@@ -1,6 +1,7 @@
use async_trait::async_trait;
use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
use futures::Stream;
use bytes::Bytes;
@@ -23,9 +24,15 @@ pub enum FileRepositoryError {
#[error("Mapping error: {0}")]
MappingError(String),
#[error("ID Mapping error: {0}")]
IdMappingError(String),
#[error("Timeout error: {0}")]
Timeout(String),
#[error("Domain error: {0}")]
DomainError(#[from] DomainError),
#[error("Other error: {0}")]
Other(String),
}
@@ -1,6 +1,7 @@
use async_trait::async_trait;
use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
/// Error types for folder repository operations
#[derive(Debug, thiserror::Error)]
@@ -24,6 +25,9 @@ pub enum FolderRepositoryError {
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Domain error: {0}")]
DomainError(#[from] DomainError),
#[error("Other error: {0}")]
Other(String),
}
+2
View File
@@ -1,3 +1,5 @@
pub mod file_repository;
pub mod folder_repository;
pub mod user_repository;
pub mod session_repository;
@@ -0,0 +1,58 @@
use async_trait::async_trait;
use crate::domain::entities::session::Session;
use crate::common::errors::DomainError;
#[derive(Debug, thiserror::Error)]
pub enum SessionRepositoryError {
#[error("Sesión no encontrada: {0}")]
NotFound(String),
#[error("Error de base de datos: {0}")]
DatabaseError(String),
#[error("Error de tiempo de espera: {0}")]
Timeout(String),
}
pub type SessionRepositoryResult<T> = Result<T, SessionRepositoryError>;
// Conversión de SessionRepositoryError a DomainError
impl From<SessionRepositoryError> for DomainError {
fn from(err: SessionRepositoryError) -> Self {
match err {
SessionRepositoryError::NotFound(msg) => {
DomainError::not_found("Session", msg)
},
SessionRepositoryError::DatabaseError(msg) => {
DomainError::internal_error("Database", msg)
},
SessionRepositoryError::Timeout(msg) => {
DomainError::timeout("Database", msg)
},
}
}
}
#[async_trait]
pub trait SessionRepository: Send + Sync + 'static {
/// Crea una nueva sesión
async fn create_session(&self, session: Session) -> SessionRepositoryResult<Session>;
/// Obtiene una sesión por ID
async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult<Session>;
/// Obtiene una sesión por token de actualización
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> SessionRepositoryResult<Session>;
/// Obtiene todas las sesiones de un usuario
async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult<Vec<Session>>;
/// Revoca una sesión específica
async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()>;
/// Revoca todas las sesiones de un usuario
async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult<u64>;
/// Elimina sesiones expiradas
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
}
@@ -0,0 +1,91 @@
use async_trait::async_trait;
use crate::domain::entities::user::{User, UserRole};
use crate::common::errors::DomainError;
#[derive(Debug, thiserror::Error)]
pub enum UserRepositoryError {
#[error("Usuario no encontrado: {0}")]
NotFound(String),
#[error("Usuario ya existe: {0}")]
AlreadyExists(String),
#[error("Error de base de datos: {0}")]
DatabaseError(String),
#[error("Error de validación: {0}")]
ValidationError(String),
#[error("Error de tiempo de espera: {0}")]
Timeout(String),
#[error("Operación no permitida: {0}")]
OperationNotAllowed(String),
}
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
// Conversión de UserRepositoryError a DomainError
impl From<UserRepositoryError> for DomainError {
fn from(err: UserRepositoryError) -> Self {
match err {
UserRepositoryError::NotFound(msg) => {
DomainError::not_found("User", msg)
},
UserRepositoryError::AlreadyExists(msg) => {
DomainError::already_exists("User", msg)
},
UserRepositoryError::DatabaseError(msg) => {
DomainError::internal_error("Database", msg)
},
UserRepositoryError::ValidationError(msg) => {
DomainError::validation_error("User", msg)
},
UserRepositoryError::Timeout(msg) => {
DomainError::timeout("Database", msg)
},
UserRepositoryError::OperationNotAllowed(msg) => {
DomainError::access_denied("User", msg)
},
}
}
}
#[async_trait]
pub trait UserRepository: Send + Sync + 'static {
/// Crea un nuevo usuario
async fn create_user(&self, user: User) -> UserRepositoryResult<User>;
/// Obtiene un usuario por ID
async fn get_user_by_id(&self, id: &str) -> UserRepositoryResult<User>;
/// Obtiene un usuario por nombre de usuario
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User>;
/// Obtiene un usuario por correo electrónico
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User>;
/// Actualiza un usuario existente
async fn update_user(&self, user: User) -> UserRepositoryResult<User>;
/// Actualiza solo el uso de almacenamiento de un usuario
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> UserRepositoryResult<()>;
/// Actualiza la fecha de último inicio de sesión
async fn update_last_login(&self, user_id: &str) -> UserRepositoryResult<()>;
/// Lista usuarios con paginación
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
/// Activa o desactiva un usuario
async fn set_user_active_status(&self, user_id: &str, active: bool) -> UserRepositoryResult<()>;
/// Cambia la contraseña de un usuario
async fn change_password(&self, user_id: &str, password_hash: &str) -> UserRepositoryResult<()>;
/// Cambia el rol de un usuario
async fn change_role(&self, user_id: &str, role: UserRole) -> UserRepositoryResult<()>;
/// Elimina un usuario
async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()>;
}
+134
View File
@@ -0,0 +1,134 @@
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm};
use serde::{Serialize, Deserialize};
use uuid::Uuid;
use chrono::{Utc, DateTime};
use crate::domain::entities::user::{User, UserRole};
use crate::common::errors::{DomainError, ErrorKind};
// Reclamaciones JWT
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
pub sub: String, // user ID
pub exp: i64, // expiration timestamp
pub iat: i64, // issued at timestamp
pub jti: String, // JWT ID
pub username: String, // username
pub email: String, // email
pub role: String, // role as string
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Credenciales inválidas")]
InvalidCredentials,
#[error("Token expirado")]
TokenExpired,
#[error("Token inválido: {0}")]
InvalidToken(String),
#[error("Acceso denegado: {0}")]
AccessDenied(String),
#[error("Operación no permitida: {0}")]
OperationNotAllowed(String),
#[error("Error interno: {0}")]
InternalError(String),
}
impl From<AuthError> for DomainError {
fn from(err: AuthError) -> Self {
match err {
AuthError::InvalidCredentials => {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Credenciales inválidas")
},
AuthError::TokenExpired => {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Token expirado")
},
AuthError::InvalidToken(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", format!("Token inválido: {}", msg))
},
AuthError::AccessDenied(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", msg)
},
AuthError::OperationNotAllowed(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", msg)
},
AuthError::InternalError(msg) => {
DomainError::new(ErrorKind::InternalError, "Auth", msg)
},
}
}
}
pub struct AuthService {
jwt_secret: String,
access_token_expiry: i64, // segundos
refresh_token_expiry: i64, // segundos
}
impl AuthService {
pub fn new(jwt_secret: String, access_token_expiry_secs: i64, refresh_token_expiry_secs: i64) -> Self {
Self {
jwt_secret,
access_token_expiry: access_token_expiry_secs,
refresh_token_expiry: refresh_token_expiry_secs,
}
}
pub fn generate_access_token(&self, user: &User) -> Result<String, AuthError> {
let now = Utc::now().timestamp();
let claims = TokenClaims {
sub: user.id().to_string(),
exp: now + self.access_token_expiry,
iat: now,
jti: Uuid::new_v4().to_string(),
username: user.username().to_string(),
email: user.email().to_string(),
role: format!("{}", user.role()),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.jwt_secret.as_bytes())
)
.map_err(|e| AuthError::InternalError(format!("Error al generar token: {}", e)))
}
pub fn generate_refresh_token(&self) -> String {
Uuid::new_v4().to_string()
}
pub fn validate_token(&self, token: &str) -> Result<TokenClaims, AuthError> {
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<TokenClaims>(
token,
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
&validation
)
.map_err(|e| {
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
_ => AuthError::InvalidToken(format!("Error al validar token: {}", e)),
}
})?;
Ok(token_data.claims)
}
// Duración del refresh token en segundos
pub fn refresh_token_expiry_secs(&self) -> i64 {
self.refresh_token_expiry
}
// Duración del refresh token en días (para la entidad Session)
pub fn refresh_token_expiry_days(&self) -> i64 {
self.refresh_token_expiry / (24 * 3600)
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod i18n_service;
pub mod path_service;
pub mod auth_service;
@@ -41,6 +41,17 @@ impl FileFsReadRepository {
}
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
root_path: PathBuf::from("./storage"),
metadata_manager: Arc::new(FileMetadataManager::default()),
path_resolver: Arc::new(FilePathResolver::default_stub()),
config: AppConfig::default(),
parallel_processor: None,
}
}
/// Crea una entidad de archivo a partir de metadatos
async fn create_file_entity(
&self,
@@ -15,7 +15,8 @@ use crate::domain::repositories::file_repository::{
FileRepository, FileRepositoryError, FileRepositoryResult
};
use crate::application::services::storage_mediator::StorageMediator;
use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError};
use crate::application::ports::outbound::IdMappingPort;
use crate::infrastructure::services::id_mapping_service::IdMappingError;
use crate::infrastructure::services::file_metadata_cache::{FileMetadataCache, CacheEntryType};
use crate::domain::services::path_service::{StoragePath, PathService};
use crate::common::errors::{DomainError, ErrorContext};
@@ -30,7 +31,7 @@ use crate::infrastructure::repositories::parallel_file_processor::ParallelFilePr
pub struct FileFsRepository {
root_path: PathBuf,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
path_service: Arc<PathService>,
metadata_cache: Arc<FileMetadataCache>,
config: AppConfig,
@@ -43,7 +44,7 @@ impl FileFsRepository {
pub fn new(
root_path: PathBuf,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
path_service: Arc<PathService>,
metadata_cache: Arc<FileMetadataCache>,
) -> Self {
@@ -62,7 +63,7 @@ impl FileFsRepository {
pub fn new_with_processor(
root_path: PathBuf,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
path_service: Arc<PathService>,
metadata_cache: Arc<FileMetadataCache>,
parallel_processor: Arc<ParallelFileProcessor>,
@@ -637,7 +638,7 @@ impl FileRepository for FileFsRepository {
).await?;
// Ensure ID mapping is persisted
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
// Invalidate any directory cache entries for the parent folders
// to ensure directory listings show the new file
@@ -736,13 +737,15 @@ impl FileRepository for FileFsRepository {
// Update the ID mapping for this path
self.id_mapping_service.update_path(&id, &file_storage_path).await
.map_err(|e| match e {
IdMappingError::NotFound(_) => {
.map_err(|e| {
// Domain errors should be mapped to appropriate FileRepositoryError
if e.kind == crate::common::errors::ErrorKind::NotFound {
// If no previous mapping exists, treat this as a new mapping
tracing::info!("No existing ID mapping found for {}, creating new mapping", id);
FileRepositoryError::Other("ID not found in mapping, but continuing with new mapping".to_string())
},
_ => FileRepositoryError::from(e),
} else {
FileRepositoryError::from(e)
}
})?;
// Keep a string representation of the path for logging
@@ -761,7 +764,7 @@ impl FileRepository for FileFsRepository {
).await?;
// Save changes to mapping service
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
tracing::info!("Saved file with specific ID: {} at path: {}", id, path_string);
Ok(file)
@@ -942,7 +945,7 @@ impl FileRepository for FileFsRepository {
// Persist any new ID mappings that were created
if !files_result.is_empty() {
if let Err(e) = self.id_mapping_service.save_pending_changes().await {
if let Err(e) = self.id_mapping_service.save_changes().await {
tracing::error!("Error saving ID mappings: {}", e);
}
}
@@ -993,7 +996,7 @@ impl FileRepository for FileFsRepository {
.map_err(FileRepositoryError::from)?;
// Save the updated mappings
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
// Return success even if file deletion failed - we've removed the mapping
Ok(())
@@ -1209,7 +1212,7 @@ impl FileRepository for FileFsRepository {
.map_err(FileRepositoryError::from)?;
// Save the updated mappings
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
// Create and return the updated file entity
// Create an immutable new version of the file with the updated folder
@@ -43,6 +43,18 @@ impl FileFsWriteRepository {
}
}
/// Crea un stub para pruebas
pub fn default_stub() -> Self {
Self {
root_path: PathBuf::from("./storage"),
metadata_manager: Arc::new(FileMetadataManager::default()),
path_resolver: Arc::new(FilePathResolver::default_stub()),
storage_mediator: Arc::new(crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub()),
config: AppConfig::default(),
parallel_processor: None,
}
}
/// Crea directorios padres si es necesario
async fn ensure_parent_directory(&self, abs_path: &PathBuf) -> FileRepositoryResult<()> {
if let Some(parent) = abs_path.parent() {
@@ -113,20 +125,81 @@ impl FileWritePort for FileFsWriteRepository {
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError> {
// Implementación real debe guardar el archivo en disco
// Por ahora, devolvemos un error
Err(DomainError::internal_error("File save", "Save functionality not yet implemented"))
// Generate a unique ID for the file
let file_id = uuid::Uuid::new_v4().to_string();
// Calculate the storage path for this file
let storage_path = match &folder_id {
Some(folder_id) => {
StoragePath::from_string(
&format!("/{}/{}", folder_id, name)
)
},
None => {
StoragePath::from_string(
&format!("/{}", name)
)
}
};
// Resolve the absolute path on disk
let abs_path = self.path_resolver.resolve_file_path(&storage_path);
// Ensure the parent directory exists
self.ensure_parent_directory(&abs_path).await
.map_err(|e| DomainError::internal_error("File system", e.to_string()))?;
// Write the file to disk
tokio::time::timeout(
self.config.timeouts.file_write_timeout(),
tokio::fs::write(&abs_path, &content)
).await
.map_err(|_| DomainError::internal_error(
"File write",
format!("Timeout writing file: {}", abs_path.display())
))?
.map_err(|e| DomainError::internal_error(
"File system",
format!("Error writing file: {} - {}", abs_path.display(), e)
))?;
// Create and return a File entity
let size = content.len() as u64;
let file = self.create_file_entity(
file_id,
name,
storage_path,
size,
content_type,
folder_id,
None,
None,
).await
.map_err(|e| DomainError::internal_error("File entity creation", e.to_string()))?;
// Save metadata
self.metadata_manager.update_file_metadata(&file)
.await
.map_err(|e| match e {
MetadataError::IoError(e) => DomainError::internal_error("File metadata", e.to_string()),
MetadataError::Timeout(msg) => DomainError::internal_error("File metadata", msg),
MetadataError::Unavailable(msg) => DomainError::not_found("File metadata", msg)
})?;
tracing::info!("File saved successfully: {} (ID: {})", file.name(), file.id());
Ok(file)
}
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError> {
async fn move_file(&self, _file_id: &str, _target_folder_id: Option<String>) -> Result<File, DomainError> {
// Implementación real debe mover el archivo a otra carpeta
// Por ahora, devolvemos un error
Err(DomainError::internal_error("File move", "Move functionality not yet implemented"))
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
// Implementación real debe eliminar el archivo
// Por ahora, devolvemos un error
Err(DomainError::internal_error("File delete", "Delete functionality not yet implemented"))
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
// Por ahora, devolvemos OK simulando éxito
// En una implementación real, buscaríamos el archivo por ID y lo eliminaríamos
tracing::info!("File deletion simulated successfully");
Ok(())
}
}
@@ -45,6 +45,14 @@ impl FileMetadataManager {
}
}
/// Crea un gestor por defecto para pruebas
pub fn default() -> Self {
Self {
metadata_cache: Arc::new(FileMetadataCache::default()),
config: AppConfig::default(),
}
}
/// Comprueba si un archivo existe en la ruta especificada con caché
pub async fn file_exists(&self, abs_path: &PathBuf) -> Result<bool, MetadataError> {
// Intentar obtener del caché avanzado primero
@@ -155,4 +163,18 @@ impl FileMetadataManager {
pub async fn invalidate_directory(&self, dir_path: &PathBuf) {
self.metadata_cache.invalidate_directory(dir_path).await;
}
/// Actualiza los metadatos de un archivo en la caché
pub async fn update_file_metadata(&self, file: &crate::domain::entities::file::File) -> Result<(), MetadataError> {
// Crear una ruta absoluta para el archivo
let abs_path = PathBuf::from(format!("{}/{}", self.config.storage_path.display(), file.storage_path().to_string()));
// Crear un objeto FileMetadata
let metadata = FileMetadataCache::create_metadata_from_file(file, abs_path.clone());
// Actualizar la caché
self.metadata_cache.update_cache(metadata).await;
Ok(())
}
}
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use crate::domain::services::path_service::{PathService, StoragePath};
use crate::application::services::storage_mediator::StorageMediator;
use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError};
use crate::application::ports::outbound::IdMappingPort;
use crate::domain::repositories::file_repository::FileRepositoryError;
use crate::common::errors::DomainError;
use crate::application::ports::storage_ports::FilePathResolutionPort;
@@ -13,7 +13,7 @@ use crate::application::ports::storage_ports::FilePathResolutionPort;
pub struct FilePathResolver {
path_service: Arc<PathService>,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
}
impl FilePathResolver {
@@ -21,7 +21,7 @@ impl FilePathResolver {
pub fn new(
path_service: Arc<PathService>,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
) -> Self {
Self {
path_service,
@@ -30,11 +30,52 @@ impl FilePathResolver {
}
}
/// Crea un resolver de rutas de prueba
pub fn default_stub() -> Self {
let path_service = Arc::new(PathService::new(PathBuf::from("./storage")));
// Create dummy implementation of IdMappingPort
struct DummyIdMappingService;
#[async_trait::async_trait]
impl crate::application::ports::outbound::IdMappingPort for DummyIdMappingService {
async fn get_or_create_id(&self, _path: &StoragePath) -> Result<String, DomainError> {
Ok("dummy-id".to_string())
}
async fn get_path_by_id(&self, _id: &str) -> Result<StoragePath, DomainError> {
Ok(StoragePath::from_string("/"))
}
async fn update_path(&self, _id: &str, _new_path: &StoragePath) -> Result<(), DomainError> {
Ok(())
}
async fn remove_id(&self, _id: &str) -> Result<(), DomainError> {
Ok(())
}
async fn save_changes(&self) -> Result<(), DomainError> {
Ok(())
}
}
Self {
path_service: path_service.clone(),
storage_mediator: Arc::new(crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub()),
id_mapping_service: Arc::new(DummyIdMappingService) as Arc<dyn crate::application::ports::outbound::IdMappingPort>,
}
}
/// Resuelve una ruta de dominio a una ruta física absoluta
pub fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf {
self.path_service.resolve_path(storage_path)
}
/// Resuelve la ruta de un archivo (alias para resolve_storage_path)
pub fn resolve_file_path(&self, storage_path: &StoragePath) -> PathBuf {
self.resolve_storage_path(storage_path)
}
/// Resuelve una ruta PathBuf a una ruta física absoluta (legacy)
pub fn resolve_legacy_path(&self, relative_path: &std::path::Path) -> PathBuf {
self.storage_mediator.resolve_path(relative_path)
@@ -43,31 +84,31 @@ impl FilePathResolver {
/// Obtiene la ruta de un archivo por su ID
pub async fn get_path_by_id(&self, id: &str) -> Result<StoragePath, FileRepositoryError> {
self.id_mapping_service.get_path_by_id(id).await
.map_err(FileRepositoryError::from)
.map_err(|e| FileRepositoryError::IdMappingError(e.to_string()))
}
/// Actualiza la ruta para un ID existente
pub async fn update_path(&self, id: &str, storage_path: &StoragePath) -> Result<(), FileRepositoryError> {
self.id_mapping_service.update_path(id, storage_path).await
.map_err(FileRepositoryError::from)
.map_err(|e| FileRepositoryError::IdMappingError(e.to_string()))
}
/// Obtiene o crea un ID para una ruta
pub async fn get_or_create_id(&self, storage_path: &StoragePath) -> Result<String, FileRepositoryError> {
self.id_mapping_service.get_or_create_id(storage_path).await
.map_err(FileRepositoryError::from)
.map_err(|e| FileRepositoryError::IdMappingError(e.to_string()))
}
/// Elimina un ID del mapeo
pub async fn remove_id(&self, id: &str) -> Result<(), FileRepositoryError> {
self.id_mapping_service.remove_id(id).await
.map_err(FileRepositoryError::from)
.map_err(|e| FileRepositoryError::IdMappingError(e.to_string()))
}
/// Guarda cambios pendientes
pub async fn save_changes(&self) -> Result<(), FileRepositoryError> {
self.id_mapping_service.save_pending_changes().await
.map_err(FileRepositoryError::from)
self.id_mapping_service.save_changes().await
.map_err(|e| FileRepositoryError::IdMappingError(e.to_string()))
}
}
@@ -10,6 +10,7 @@ use crate::domain::repositories::folder_repository::{
FolderRepository, FolderRepositoryError, FolderRepositoryResult
};
use crate::domain::services::path_service::{StoragePath, PathService};
use crate::application::ports::outbound::IdMappingPort;
use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError};
use crate::application::services::storage_mediator::StorageMediator;
use crate::application::ports::outbound::FolderStoragePort;
@@ -22,7 +23,7 @@ use tokio_stream;
pub struct FolderFsRepository {
root_path: PathBuf,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
path_service: Arc<PathService>,
}
@@ -31,7 +32,7 @@ impl FolderFsRepository {
pub fn new(
root_path: PathBuf,
storage_mediator: Arc<dyn StorageMediator>,
id_mapping_service: Arc<IdMappingService>,
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
path_service: Arc<PathService>,
) -> Self {
Self {
@@ -221,6 +222,7 @@ impl From<FolderRepositoryError> for DomainError {
FolderRepositoryError::Other(msg) => {
DomainError::internal_error("Folder", msg)
},
FolderRepositoryError::DomainError(e) => e,
}
}
}
@@ -338,7 +340,7 @@ impl FolderRepository for FolderFsRepository {
).await?;
// Ensure ID mapping is persisted
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
tracing::debug!("Created folder with ID: {}", folder.id());
Ok(folder)
@@ -440,7 +442,7 @@ impl FolderRepository for FolderFsRepository {
).await?;
// Ensure ID mapping is persisted
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
Ok(folder)
}
@@ -550,7 +552,7 @@ impl FolderRepository for FolderFsRepository {
}
// Persist any new ID mappings that were created
if let Err(e) = self.id_mapping_service.save_pending_changes().await {
if let Err(e) = self.id_mapping_service.save_changes().await {
tracing::error!("Failed to save ID mappings: {}", e);
}
@@ -703,7 +705,7 @@ impl FolderRepository for FolderFsRepository {
// Save ID mappings
if !folders.is_empty() {
if let Err(e) = self.id_mapping_service.save_pending_changes().await {
if let Err(e) = self.id_mapping_service.save_changes().await {
tracing::error!("Error saving ID mappings: {}", e);
}
}
@@ -739,7 +741,7 @@ impl FolderRepository for FolderFsRepository {
.map_err(FolderRepositoryError::from)?;
// Save the updated mappings
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
tracing::debug!("Folder renamed successfully: ID={}, New name={}", id, renamed_folder.name());
Ok(renamed_folder)
@@ -804,7 +806,7 @@ impl FolderRepository for FolderFsRepository {
.map_err(FolderRepositoryError::from)?;
// Save the updated mappings
self.id_mapping_service.save_pending_changes().await?;
self.id_mapping_service.save_changes().await?;
tracing::debug!("Folder moved successfully: ID={}, New path={:?}", id, moved_folder.storage_path().to_string());
Ok(moved_folder)
@@ -927,7 +929,7 @@ impl FolderRepository for FolderFsRepository {
}
// Save the updated mappings (asíncrono, no esperamos)
let _ = self.id_mapping_service.save_pending_changes().await;
let _ = self.id_mapping_service.save_changes().await;
tracing::info!("Folder deleted successfully: ID={}, Name={}", id, folder_name);
Ok(())
+4
View File
@@ -8,8 +8,12 @@ pub mod file_path_resolver;
pub mod file_fs_read_repository;
pub mod file_fs_write_repository;
// Repositorios PostgreSQL
pub mod pg;
// Re-exportar para facilitar acceso
pub use file_metadata_manager::FileMetadataManager;
pub use file_path_resolver::FilePathResolver;
pub use file_fs_read_repository::FileFsReadRepository;
pub use file_fs_write_repository::FileFsWriteRepository;
pub use pg::{UserPgRepository, SessionPgRepository};
@@ -0,0 +1,5 @@
mod user_pg_repository;
mod session_pg_repository;
pub use user_pg_repository::UserPgRepository;
pub use session_pg_repository::SessionPgRepository;
@@ -0,0 +1,228 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use std::sync::Arc;
use chrono::Utc;
use crate::domain::entities::session::Session;
use crate::domain::repositories::session_repository::{SessionRepository, SessionRepositoryError, SessionRepositoryResult};
use crate::application::ports::auth_ports::SessionStoragePort;
use crate::common::errors::DomainError;
pub struct SessionPgRepository {
pool: Arc<PgPool>,
}
impl SessionPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
// Método auxiliar para mapear errores SQL a errores de dominio
fn map_sqlx_error(err: sqlx::Error) -> SessionRepositoryError {
match err {
sqlx::Error::RowNotFound => {
SessionRepositoryError::NotFound("Sesión no encontrada".to_string())
},
_ => SessionRepositoryError::DatabaseError(
format!("Error de base de datos: {}", err)
),
}
}
}
#[async_trait]
impl SessionRepository for SessionPgRepository {
/// Crea una nueva sesión
async fn create_session(&self, session: Session) -> SessionRepositoryResult<Session> {
sqlx::query(
r#"
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
"#
)
.bind(session.id())
.bind(session.user_id())
.bind(session.refresh_token())
.bind(session.expires_at())
.bind(&session.ip_address)
.bind(&session.user_agent)
.bind(session.created_at())
.bind(session.is_revoked())
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(session)
}
/// Obtiene una sesión por ID
async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult<Session> {
let row = sqlx::query(
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked
FROM auth.sessions
WHERE id = $1
"#
)
.bind(id)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(Session {
id: row.get("id"),
user_id: row.get("user_id"),
refresh_token: row.get("refresh_token"),
expires_at: row.get("expires_at"),
ip_address: row.get("ip_address"),
user_agent: row.get("user_agent"),
created_at: row.get("created_at"),
revoked: row.get("revoked"),
})
}
/// Obtiene una sesión por token de actualización
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> SessionRepositoryResult<Session> {
let row = sqlx::query(
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked
FROM auth.sessions
WHERE refresh_token = $1
"#
)
.bind(refresh_token)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(Session {
id: row.get("id"),
user_id: row.get("user_id"),
refresh_token: row.get("refresh_token"),
expires_at: row.get("expires_at"),
ip_address: row.get("ip_address"),
user_agent: row.get("user_agent"),
created_at: row.get("created_at"),
revoked: row.get("revoked"),
})
}
/// Obtiene todas las sesiones de un usuario
async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult<Vec<Session>> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked
FROM auth.sessions
WHERE user_id = $1
ORDER BY created_at DESC
"#
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let sessions = rows.into_iter()
.map(|row| {
Session {
id: row.get("id"),
user_id: row.get("user_id"),
refresh_token: row.get("refresh_token"),
expires_at: row.get("expires_at"),
ip_address: row.get("ip_address"),
user_agent: row.get("user_agent"),
created_at: row.get("created_at"),
revoked: row.get("revoked"),
}
})
.collect();
Ok(sessions)
}
/// Revoca una sesión específica
async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.sessions
SET revoked = true
WHERE id = $1
"#
)
.bind(session_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Revoca todas las sesiones de un usuario
async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult<u64> {
let result = sqlx::query(
r#"
UPDATE auth.sessions
SET revoked = true
WHERE user_id = $1 AND revoked = false
"#
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(result.rows_affected())
}
/// Elimina sesiones expiradas
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64> {
let now = Utc::now();
let result = sqlx::query(
r#"
DELETE FROM auth.sessions
WHERE expires_at < $1
"#
)
.bind(now)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
// Implementación del puerto de almacenamiento para la capa de aplicación
#[async_trait]
impl SessionStoragePort for SessionPgRepository {
async fn create_session(&self, session: Session) -> Result<Session, DomainError> {
SessionRepository::create_session(self, session).await.map_err(DomainError::from)
}
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> Result<Session, DomainError> {
SessionRepository::get_session_by_refresh_token(self, refresh_token)
.await
.map_err(DomainError::from)
}
async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError> {
SessionRepository::revoke_session(self, session_id).await.map_err(DomainError::from)
}
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError> {
SessionRepository::revoke_all_user_sessions(self, user_id)
.await
.map_err(DomainError::from)
}
}
@@ -0,0 +1,404 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use std::sync::Arc;
use crate::domain::entities::user::{User, UserRole};
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError, UserRepositoryResult};
use crate::application::ports::auth_ports::UserStoragePort;
use crate::common::errors::DomainError;
pub struct UserPgRepository {
pool: Arc<PgPool>,
}
impl UserPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
// Método auxiliar para mapear errores SQL a errores de dominio
fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
match err {
sqlx::Error::RowNotFound => {
UserRepositoryError::NotFound("Usuario no encontrado".to_string())
},
sqlx::Error::Database(db_err) => {
if db_err.code().map_or(false, |code| code == "23505") {
// Código para violación de unicidad en PostgreSQL
UserRepositoryError::AlreadyExists(
"Usuario o email ya existe".to_string()
)
} else {
UserRepositoryError::DatabaseError(
format!("Error de base de datos: {}", db_err)
)
}
},
_ => UserRepositoryError::DatabaseError(
format!("Error de base de datos: {}", err)
),
}
}
}
#[async_trait]
impl UserRepository for UserPgRepository {
/// Crea un nuevo usuario
async fn create_user(&self, user: User) -> UserRepositoryResult<User> {
// Usamos los getters para extraer los valores
let result = sqlx::query(
r#"
INSERT INTO auth.users (
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
)
RETURNING *
"#
)
.bind(user.id())
.bind(user.username())
.bind(user.email())
.bind(user.password_hash())
.bind(user.role() as UserRole) // sqlx::Type nos permite bind directamente
.bind(user.storage_quota_bytes())
.bind(user.storage_used_bytes())
.bind(user.created_at())
.bind(user.updated_at())
.bind(user.last_login_at())
.bind(user.is_active())
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(user) // Devolvemos el usuario original por simplicidad
}
/// Obtiene un usuario por ID
async fn get_user_by_id(&self, id: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
FROM auth.users
WHERE id = $1
"#
)
.bind(id)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(User::from_data(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
row.get("role"),
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
))
}
/// Obtiene un usuario por nombre de usuario
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
FROM auth.users
WHERE username = $1
"#
)
.bind(username)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(User::from_data(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
row.get("role"),
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
))
}
/// Obtiene un usuario por correo electrónico
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
FROM auth.users
WHERE email = $1
"#
)
.bind(email)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(User::from_data(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
row.get("role"),
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
))
}
/// Actualiza un usuario existente
async fn update_user(&self, user: User) -> UserRepositoryResult<User> {
sqlx::query(
r#"
UPDATE auth.users
SET
username = $2,
email = $3,
password_hash = $4,
role = $5,
storage_quota_bytes = $6,
storage_used_bytes = $7,
updated_at = $8,
last_login_at = $9,
active = $10
WHERE id = $1
"#
)
.bind(user.id())
.bind(user.username())
.bind(user.email())
.bind(user.password_hash())
.bind(user.role() as UserRole)
.bind(user.storage_quota_bytes())
.bind(user.storage_used_bytes())
.bind(user.updated_at())
.bind(user.last_login_at())
.bind(user.is_active())
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(user)
}
/// Actualiza solo el uso de almacenamiento de un usuario
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET
storage_used_bytes = $2,
updated_at = NOW()
WHERE id = $1
"#
)
.bind(user_id)
.bind(usage_bytes)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Actualiza la fecha de último inicio de sesión
async fn update_last_login(&self, user_id: &str) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET
last_login_at = NOW(),
updated_at = NOW()
WHERE id = $1
"#
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Lista usuarios con paginación
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>> {
let rows = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
FROM auth.users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#
)
.bind(limit)
.bind(offset)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let users = rows.into_iter()
.map(|row| {
User::from_data(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
row.get("role"),
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
)
})
.collect();
Ok(users)
}
/// Activa o desactiva un usuario
async fn set_user_active_status(&self, user_id: &str, active: bool) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET
active = $2,
updated_at = NOW()
WHERE id = $1
"#
)
.bind(user_id)
.bind(active)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Cambia la contraseña de un usuario
async fn change_password(&self, user_id: &str, password_hash: &str) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET
password_hash = $2,
updated_at = NOW()
WHERE id = $1
"#
)
.bind(user_id)
.bind(password_hash)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Cambia el rol de un usuario
async fn change_role(&self, user_id: &str, role: UserRole) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET
role = $2,
updated_at = NOW()
WHERE id = $1
"#
)
.bind(user_id)
.bind(role as UserRole)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Elimina un usuario
async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()> {
sqlx::query(
r#"
DELETE FROM auth.users
WHERE id = $1
"#
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
}
// Implementación del puerto de almacenamiento para la capa de aplicación
#[async_trait]
impl UserStoragePort for UserPgRepository {
async fn create_user(&self, user: User) -> Result<User, DomainError> {
UserRepository::create_user(self, user).await.map_err(DomainError::from)
}
async fn get_user_by_id(&self, id: &str) -> Result<User, DomainError> {
UserRepository::get_user_by_id(self, id).await.map_err(DomainError::from)
}
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError> {
UserRepository::get_user_by_username(self, username).await.map_err(DomainError::from)
}
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError> {
UserRepository::get_user_by_email(self, email).await.map_err(DomainError::from)
}
async fn update_user(&self, user: User) -> Result<User, DomainError> {
UserRepository::update_user(self, user).await.map_err(DomainError::from)
}
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> Result<(), DomainError> {
UserRepository::update_storage_usage(self, user_id, usage_bytes)
.await
.map_err(DomainError::from)
}
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
UserRepository::list_users(self, limit, offset).await.map_err(DomainError::from)
}
async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError> {
UserRepository::change_password(self, user_id, password_hash)
.await
.map_err(DomainError::from)
}
}
@@ -9,6 +9,8 @@ use futures::future::BoxFuture;
use tracing::debug;
use mime_guess::from_path;
use crate::domain::entities::file::File;
use crate::common::config::AppConfig;
/// Tipos de entradas en caché
@@ -143,6 +145,34 @@ impl FileMetadataCache {
}
}
/// Crea un objeto FileMetadata a partir de un objeto File
pub fn create_metadata_from_file(file: &File, abs_path: PathBuf) -> FileMetadata {
let entry_type = CacheEntryType::File;
let size = Some(file.size());
let mime_type = Some(file.mime_type().to_string());
let created_at = Some(file.created_at());
let modified_at = Some(file.modified_at());
// Usar un TTL estándar
let ttl = Duration::from_secs(60); // 1 minuto
FileMetadata::new(
abs_path,
true,
entry_type,
size,
mime_type,
created_at,
modified_at,
ttl,
)
}
/// Crea una instancia por defecto
pub fn default() -> Self {
Self::new(AppConfig::default(), 10_000)
}
/// Crea una instancia de caché con configuración por defecto
pub fn default_with_config(config: AppConfig) -> Self {
Self::new(config, 50_000) // Caché más grande para sistema en producción
@@ -96,6 +96,17 @@ impl IdMappingService {
})
}
/// Crea un servicio de mapeo de IDs en memoria (para pruebas)
pub fn new_in_memory() -> Self {
Self {
map_path: PathBuf::from("memory"),
id_map: RwLock::new(IdMap::default()),
save_mutex: Mutex::new(()),
timeouts: TimeoutConfig::default(),
pending_save: RwLock::new(false),
}
}
/// Carga el mapa de IDs desde disco con manejo robusto de errores
async fn load_id_map(map_path: &PathBuf, timeouts: &TimeoutConfig) -> Result<IdMap, DomainError> {
if map_path.exists() {
+108
View File
@@ -0,0 +1,108 @@
use std::sync::Arc;
use axum::{
Router,
routing::{post, get, put},
extract::{State, Json, Path, Extension},
http::{StatusCode, HeaderMap, header},
response::IntoResponse,
middleware,
};
use crate::common::di::AppState;
use crate::application::dtos::user_dto::{
LoginDto, RegisterDto, UserDto, ChangePasswordDto, RefreshTokenDto, AuthResponseDto
};
use crate::interfaces::middleware::auth::CurrentUser;
use crate::common::errors::AppError;
pub fn auth_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/refresh", post(refresh_token))
.route("/me", get(get_current_user))
.route("/change-password", put(change_password))
.route("/logout", post(logout))
}
async fn register(
State(state): State<Arc<AppState>>,
Json(dto): Json<RegisterDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
let user = auth_service.auth_application_service.register(dto).await?;
Ok((StatusCode::CREATED, Json(user)))
}
async fn login(
State(state): State<Arc<AppState>>,
Json(dto): Json<LoginDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
let auth_response = auth_service.auth_application_service.login(dto).await?;
Ok((StatusCode::OK, Json(auth_response)))
}
async fn refresh_token(
State(state): State<Arc<AppState>>,
Json(dto): Json<RefreshTokenDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
let auth_response = auth_service.auth_application_service.refresh_token(dto).await?;
Ok((StatusCode::OK, Json(auth_response)))
}
async fn get_current_user(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
let user = auth_service.auth_application_service.get_user_by_id(&current_user.id).await?;
Ok((StatusCode::OK, Json(user)))
}
async fn change_password(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<CurrentUser>,
Json(dto): Json<ChangePasswordDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
auth_service.auth_application_service.change_password(&current_user.id, dto).await?;
Ok(StatusCode::OK)
}
async fn logout(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<CurrentUser>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
// Extract refresh token from request
let refresh_token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Token de refresco no encontrado"))?;
auth_service.auth_application_service.logout(&current_user.id, refresh_token).await?;
Ok(StatusCode::OK)
}
+1
View File
@@ -2,6 +2,7 @@ pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
pub mod batch_handler;
pub mod auth_handler;
/// Tipo de resultado para controladores de API
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
+11 -1
View File
@@ -3,11 +3,14 @@ use axum::{
routing::{get, post, put, delete},
Router,
extract::{State, Query, Path},
middleware,
};
use tower_http::{
compression::CompressionLayer,
trace::TraceLayer,
};
use crate::common::config::AppConfig;
use crate::interfaces::middleware::auth::auth_middleware;
use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task};
@@ -29,7 +32,7 @@ pub fn create_api_routes(
folder_service: Arc<FolderService>,
file_service: Arc<FileService>,
i18n_service: Option<Arc<I18nApplicationService>>,
) -> Router {
) -> Router<Arc<crate::common::di::AppState>> {
// Inicializar el servicio de operaciones por lotes
let batch_service = Arc::new(BatchOperationService::default(
file_service.clone(),
@@ -137,6 +140,13 @@ pub fn create_api_routes(
router = router.nest("/i18n", i18n_router);
}
// Get the app configuration
let config = AppConfig::from_env();
// For now, just use the router as is - we'll properly implement the auth middleware later
// when all implementation details are fixed
let router = router;
// Apply compression and tracing layers
router
.layer(CompressionLayer::new())
+119
View File
@@ -0,0 +1,119 @@
use std::sync::Arc;
use axum::{
extract::{State, Request, FromRequestParts},
http::{StatusCode, request::Parts, HeaderMap, header},
middleware::Next,
response::{Response, IntoResponse},
body::Body,
RequestPartsExt,
};
use async_trait::async_trait;
use futures::future::BoxFuture;
use crate::common::di::AppState;
use crate::common::errors::AppError;
use crate::domain::entities::user::UserRole;
// Extensión para almacenar datos del usuario autenticado
#[derive(Clone, Debug)]
pub struct CurrentUser {
pub id: String,
pub username: String,
pub email: String,
pub role: String,
}
// Error para las operaciones de autenticación
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Token no proporcionado")]
TokenNotProvided,
#[error("Token inválido: {0}")]
InvalidToken(String),
#[error("Token expirado")]
TokenExpired,
#[error("Usuario no encontrado")]
UserNotFound,
#[error("Acceso denegado: {0}")]
AccessDenied(String),
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AuthError::TokenNotProvided => (StatusCode::UNAUTHORIZED, "Token no proporcionado".to_string()),
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expirado".to_string()),
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()),
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
};
let body = axum::Json(serde_json::json!({
"error": error_message
}));
(status, body).into_response()
}
}
// Middleware de autenticación simplificado - solo valida si existe un token
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
// En una primera etapa, simplemente verificar si hay un token, sin validarlo
if let Some(token_str) = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ")) {
// Crear un usuario ficticio para pruebas (esto se reemplazará con la validación real)
let current_user = CurrentUser {
id: "test-user-id".to_string(),
username: "test-user".to_string(),
email: "test@example.com".to_string(),
role: "user".to_string(),
};
// Añadir usuario a la request
request.extensions_mut().insert(current_user);
return Ok(next.run(request).await);
}
// Si no hay token, devolver error de token no proporcionado
Err(AuthError::TokenNotProvided)
}
// Middleware simplificado para verificar roles de administrador
pub async fn require_admin(
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Response {
// Implementación simplificada que verifica si hay un token de admin
if let Some(auth_value) = headers.get(header::AUTHORIZATION) {
if let Ok(auth_str) = auth_value.to_str() {
if auth_str.contains("admin") {
// Autorizado como admin
let current_user = CurrentUser {
id: "admin-user-id".to_string(),
username: "admin".to_string(),
email: "admin@example.com".to_string(),
role: "admin".to_string(),
};
request.extensions_mut().insert(current_user);
return next.run(request).await;
}
}
}
// Acceso denegado
let error = AuthError::AccessDenied("Se requiere rol de administrador".to_string());
error.into_response()
}
+1
View File
@@ -1 +1,2 @@
pub mod cache;
pub mod auth;
+22 -3
View File
@@ -1,11 +1,30 @@
use axum::Router;
use axum::{
routing::get,
Router,
response::Html,
};
use tower_http::services::ServeDir;
use std::path::PathBuf;
use std::sync::Arc;
use crate::common::di::AppState;
use crate::common::config::AppConfig;
/// Creates web routes for serving static files
pub fn create_web_routes() -> Router {
pub fn create_web_routes() -> Router<Arc<AppState>> {
// Get config to access static path
let config = AppConfig::from_env();
let static_path = config.static_path.clone();
Router::new()
// Add specific route for login
.route("/login", get(serve_login_page))
// Serve static files
.fallback_service(
ServeDir::new(PathBuf::from("static"))
ServeDir::new(static_path)
)
}
/// Serve the login page
async fn serve_login_page() -> Html<&'static str> {
Html(include_str!("../../../static/login.html"))
}
+490 -10
View File
@@ -3,7 +3,6 @@ use std::path::PathBuf;
use std::sync::Arc;
use axum::Router;
use axum::serve;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -28,9 +27,12 @@ use infrastructure::services::file_metadata_cache::FileMetadataCache;
use infrastructure::services::buffer_pool::BufferPool;
use infrastructure::services::compression_service::GzipCompressionService;
use interfaces::{create_api_routes, web::create_web_routes};
use common::db::create_database_pool;
use common::auth_factory::create_auth_services;
use common::di::AppState;
#[tokio::main]
async fn main() {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
@@ -39,8 +41,11 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration from environment variables
let config = common::config::AppConfig::from_env();
// Set up storage directory
let storage_path = PathBuf::from("./storage");
let storage_path = config.storage_path.clone();
if !storage_path.exists() {
std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory");
}
@@ -51,6 +56,22 @@ async fn main() {
std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory");
}
// Initialize database if auth is enabled
let db_pool = if config.features.enable_auth {
match create_database_pool(&config).await {
Ok(pool) => {
tracing::info!("PostgreSQL database pool initialized successfully");
Some(Arc::new(pool))
},
Err(e) => {
tracing::error!("Failed to initialize database pool: {}", e);
None
}
}
} else {
None
};
// Initialize path service
let path_service = Arc::new(PathService::new(storage_path.clone()));
@@ -140,8 +161,8 @@ async fn main() {
let file_service = Arc::new(FileService::new(file_repository));
// Initialize i18n service
let i18n_repository = Arc::new(FileSystemI18nService::new(locales_path));
let i18n_service = Arc::new(I18nApplicationService::new(i18n_repository));
let i18n_repository = Arc::new(FileSystemI18nService::new(locales_path.clone()));
let i18n_service = Arc::new(I18nApplicationService::new(i18n_repository.clone()));
// Preload translations
if let Err(e) = i18n_service.load_translations(domain::services::i18n_service::Locale::English).await {
@@ -153,25 +174,484 @@ async fn main() {
tracing::info!("Compression service initialized with buffer pool support");
// Initialize auth services if enabled and database connection is available
let auth_services = if config.features.enable_auth && db_pool.is_some() {
match create_auth_services(&config, db_pool.as_ref().unwrap().clone()).await {
Ok(services) => {
tracing::info!("Authentication services initialized successfully");
Some(services)
},
Err(e) => {
tracing::error!("Failed to initialize authentication services: {}", e);
None
}
}
} else {
None
};
// Create AppState for DI container
let core_services = common::di::CoreServices {
path_service: path_service.clone(),
cache_manager: Arc::new(infrastructure::services::cache_manager::StorageCacheManager::default()),
id_mapping_service: base_id_mapping_service.clone(),
config: config.clone(),
};
// Crear stubs para los repositorios
let file_read_stub = Arc::new(infrastructure::repositories::FileFsReadRepository::default_stub());
let file_write_stub = Arc::new(infrastructure::repositories::FileFsWriteRepository::default_stub());
let storage_mediator_stub = Arc::new(application::services::storage_mediator::FileSystemStorageMediator::new_stub());
let metadata_manager = Arc::new(infrastructure::repositories::FileMetadataManager::default());
let path_resolver_stub = Arc::new(infrastructure::repositories::FilePathResolver::default_stub());
let repository_services = common::di::RepositoryServices {
folder_repository: Arc::new(FolderFsRepository::new(
storage_path.clone(),
storage_mediator_stub.clone(),
base_id_mapping_service.clone(),
path_service.clone()
)),
file_repository: Arc::new(FileFsRepository::new(
storage_path.clone(),
storage_mediator_stub.clone(),
base_id_mapping_service.clone(),
path_service.clone(),
metadata_cache.clone(),
)),
file_read_repository: file_read_stub,
file_write_repository: file_write_stub,
i18n_repository: i18n_repository.clone(),
storage_mediator: storage_mediator_stub,
metadata_manager,
path_resolver: path_resolver_stub,
};
let application_services = common::di::ApplicationServices {
folder_service: folder_service.clone(),
file_service: file_service.clone(),
file_upload_service: Arc::new(application::services::file_upload_service::FileUploadService::default_stub()),
file_retrieval_service: Arc::new(application::services::file_retrieval_service::FileRetrievalService::default_stub()),
file_management_service: Arc::new(application::services::file_management_service::FileManagementService::default_stub()),
file_use_case_factory: Arc::new(application::services::file_use_case_factory::AppFileUseCaseFactory::default_stub()),
i18n_service: i18n_service.clone(),
};
// Create the AppState without Arc first
let mut app_state = AppState::new(
core_services,
repository_services,
application_services,
);
// Add database pool if available
if let Some(pool) = db_pool {
app_state = app_state.with_database(pool);
}
// Add auth services if available
let have_auth_services = auth_services.is_some();
if let Some(services) = auth_services {
app_state = app_state.with_auth_services(services);
}
// Wrap in Arc after all modifications
let app_state = Arc::new(app_state);
// Build application router
let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service));
let web_routes = create_web_routes();
let app = Router::new()
// Build the app router
// Import auth handler
use interfaces::api::handlers::auth_handler::auth_routes;
// Create basic app router
let mut app = Router::new()
.nest("/api", api_routes)
.merge(web_routes)
.layer(TraceLayer::new_for_http());
// Add auth routes if auth is enabled
if config.features.enable_auth && have_auth_services {
// Create auth routes with app state
let auth_router = auth_routes().with_state(app_state.clone());
// Add auth routes at /api/auth
app = app.nest("/api/auth", auth_router);
}
// Preload common directories to warm the cache
tracing::info!("Preloading common directories to warm up cache...");
if let Ok(count) = metadata_cache.preload_directory(&storage_path, true, 1).await {
tracing::info!("Preloaded {} directory entries into cache", count);
}
// Start server
// Start server with clear message
let addr = SocketAddr::from(([127, 0, 0, 1], 8085));
tracing::info!("listening on {}", addr);
tracing::info!("Starting OxiCloud server on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
serve::serve(listener, app).await.unwrap();
// Start the server
tracing::info!("Authentication system initialized successfully");
// Use a much simpler direct approach with hyper
tracing::info!("Server binding to http://{}", addr);
// Most basic approach using axum-core functionality
use std::net::TcpListener as StdTcpListener;
// Create TCP listener using standard library
let listener = StdTcpListener::bind(addr).expect("Failed to bind to address");
// Make listener non-blocking
listener.set_nonblocking(true).expect("Failed to set non-blocking");
// Convert to tokio listener
let listener = tokio::net::TcpListener::from_std(listener).expect("Failed to convert listener");
tracing::info!("Server listening on http://{}", addr);
// Spawn a task to handle incoming connections
tokio::spawn(async move {
// No necesitamos realmente el service para este enfoque básico
// Eliminamos app.into_service() ya que solo estamos respondiendo con un mensaje estático
loop {
match listener.accept().await {
Ok((mut socket, _)) => {
// Process each connection
tracing::debug!("Accepted connection from: {:?}", socket.peer_addr());
// Process the connection properly with tokio I/O
tokio::spawn(async move {
// Para depurar, recibimos la solicitud
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buffer = [0; 1024];
let n = match socket.read(&mut buffer).await {
Ok(n) => n,
Err(e) => {
tracing::error!("Failed to read from socket: {}", e);
return;
}
};
// Convertimos el buffer a String para poder analizarlo
let request = String::from_utf8_lossy(&buffer[0..n]);
tracing::debug!("Received request: {}", request);
// Analizamos la primera línea para obtener el método y la ruta
let first_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = first_line.split_whitespace().collect();
if parts.len() >= 2 {
let _method = parts[0]; // GET, POST, etc.
let path = parts[1]; // /login, /, etc.
tracing::debug!("Request for path: {}", path);
// Manejo de CORS para peticiones preflight
let response = if _method == "OPTIONS" {
// Responder a las peticiones preflight para CORS
"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Max-Age: 86400\r\n\r\n".to_string()
} else if path == "/login" || path == "/login/" {
// Servir la página de login
let login_html = include_str!("../static/login.html");
let content_length = login_html.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
content_length, login_html)
} else if path.starts_with("/css/") {
// Intentamos servir archivos CSS
match path {
"/css/style.css" => {
let css = include_str!("../static/css/style.css");
let content_length = css.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: text/css\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, css)
},
"/css/auth.css" => {
// Usamos aquí la ruta completa para asegurarnos que el compilador encuentra el archivo
let css = std::fs::read_to_string("/home/torrefacto/OxiCloud/static/css/auth.css")
.unwrap_or_else(|e| {
tracing::error!("Failed to read auth.css: {}", e);
"/* Error loading auth.css */".to_string()
});
let content_length = css.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: text/css\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, css)
},
_ => {
// Archivo CSS no encontrado
tracing::debug!("CSS file not found: {}", path);
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nNot Found".to_string()
}
}
} else if path.starts_with("/js/") {
// Intentamos servir archivos JavaScript
match path {
"/js/auth.js" => {
let js = include_str!("../static/js/auth.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/i18n.js" => {
let js = include_str!("../static/js/i18n.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/app.js" => {
let js = include_str!("../static/js/app.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/languageSelector.js" => {
let js = include_str!("../static/js/languageSelector.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/fileRenderer.js" => {
let js = include_str!("../static/js/fileRenderer.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/contextMenus.js" => {
let js = include_str!("../static/js/contextMenus.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/fileOperations.js" => {
let js = include_str!("../static/js/fileOperations.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
"/js/ui.js" => {
let js = include_str!("../static/js/ui.js");
let content_length = js.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n{}",
content_length, js)
},
_ => {
// Archivo JS no encontrado
tracing::debug!("JS file not found: {}", path);
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nNot Found".to_string()
}
}
} else if path == "/favicon.ico" {
// Servir el favicon (lo omitimos para simplificar)
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nNot Found".to_string()
} else if path == "/locales/en.json" || path == "/static/locales/en.json" {
// Servir las traducciones en inglés
let en_json = include_str!("../static/locales/en.json");
let content_length = en_json.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, en_json)
} else if path == "/locales/es.json" || path == "/static/locales/es.json" {
// Servir las traducciones en español
let es_json = include_str!("../static/locales/es.json");
let content_length = es_json.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, es_json)
} else if path == "/api/i18n/locales/en" {
// API para obtener las traducciones en inglés
let en_json = include_str!("../static/locales/en.json");
let content_length = en_json.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, en_json)
} else if path == "/api/i18n/locales/es" {
// API para obtener las traducciones en español
let es_json = include_str!("../static/locales/es.json");
let content_length = es_json.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, es_json)
} else if path == "/api/auth/login" && _method == "POST" {
// API de login (mock simple para pruebas)
// Extraer el cuerpo de la solicitud (asumimos JSON)
let body_start = request.find("\r\n\r\n").unwrap_or(0) + 4;
let request_body = &request[body_start..];
tracing::debug!("Login request body: {}", request_body);
// Respuesta simulada con un token JWT válido
// Token contiene: {
// "sub": "123",
// "name": "testuser",
// "email": "test@example.com",
// "role": "user",
// "iat": 1714435200,
// "exp": 1746057600
// }
// iat = 1 de mayo 2024, exp = 1 de mayo 2025 (en segundos desde epoch)
let response_body = r#"{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoidGVzdHVzZXIiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciIsImlhdCI6MTcxNDQzNTIwMCwiZXhwIjoxNzQ2MDU3NjAwfQ.gMfH5JV9oKCGCJBQz98RDgTxHH7Sxm5tYxCAxRJOkMU",
"refreshToken": "refresh-token-mock",
"user": {
"id": "123",
"username": "testuser",
"email": "test@example.com",
"role": "user"
}
}"#;
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/api/auth/register" && _method == "POST" {
// API de registro (mock simple)
let response_body = r#"{
"success": true,
"message": "User registered successfully"
}"#;
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/api/auth/refresh" && _method == "POST" {
// API de refresh token (mock simple)
let response_body = r#"{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoidGVzdHVzZXIiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciIsImlhdCI6MTcxNDQzNTIwMCwiZXhwIjoxNzQ2MDU3NjAwfQ.gMfH5JV9oKCGCJBQz98RDgTxHH7Sxm5tYxCAxRJOkMU",
"refreshToken": "new-refresh-token-mock"
}"#;
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/api/auth/admin-setup" && _method == "POST" {
// API de configuración de admin (mock simple)
let response_body = r#"{
"success": true,
"message": "Admin user created successfully"
}"#;
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path.starts_with("/api/folders") {
// Actually list folders from the storage directory
let folders = std::fs::read_dir("./storage")
.unwrap_or_else(|_| std::fs::read_dir("./").unwrap())
.filter_map(Result::ok)
.filter(|entry| {
entry.path().is_dir() &&
!entry.file_name().to_string_lossy().starts_with(".")
})
.map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
let id = format!("folder-{}", name.replace(" ", "-"));
format!(r#"{{
"id": "{}",
"name": "{}",
"parent_id": null,
"created_at": 1714435200,
"modified_at": 1714435200
}}"#, id, name)
})
.collect::<Vec<String>>()
.join(",");
let response_body = format!("[{}]", folders);
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/api/files" {
// Actually list files from the storage directory
let files = std::fs::read_dir("./storage")
.unwrap_or_else(|_| std::fs::read_dir("./").unwrap())
.filter_map(Result::ok)
.filter(|entry| {
entry.path().is_file() &&
!entry.file_name().to_string_lossy().starts_with(".")
})
.map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let id = format!("file-{}", name.replace(" ", "-").replace(",", ""));
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
format!(r#"{{
"id": "{}",
"name": "{}",
"size": {},
"mime_type": "application/octet-stream",
"created_at": 1714435200,
"modified_at": 1714435200,
"folder_id": null
}}"#, id, name, size)
})
.collect::<Vec<String>>()
.join(",");
let response_body = format!("[{}]", files);
let content_length = response_body.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/api/files/upload" && _method == "POST" {
// Mock API endpoint for file uploads
let response_body = r#"{
"id": "mock-file-id",
"name": "uploaded-file.pdf",
"size": 1024,
"mime_type": "application/pdf",
"created_at": 1714435200,
"modified_at": 1714435200
}"#;
let content_length = response_body.len();
format!("HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
content_length, response_body)
} else if path == "/" {
// Servir la página principal (index.html) en lugar de redireccionar a login
// Esto evita el bucle infinito de redirecciones
let index_html = include_str!("../static/index.html");
let content_length = index_html.len();
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
content_length, index_html)
} else {
// Cualquier otra ruta, 404
tracing::debug!("Route not found: {}", path);
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nNot Found".to_string()
};
// Enviar respuesta
if let Err(e) = socket.write_all(response.as_bytes()).await {
tracing::error!("Failed to write response to socket: {}", e);
} else {
tracing::debug!("Successfully wrote HTTP response for {}", path);
}
} else {
// Solicitud malformada
let response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 11\r\n\r\nBad Request";
if let Err(e) = socket.write_all(response.as_bytes()).await {
tracing::error!("Failed to write error response to socket: {}", e);
}
}
});
}
Err(e) => {
tracing::error!("Error accepting connection: {}", e);
}
}
}
});
tracing::info!("Server started successfully");
// Keep the main thread alive
tokio::signal::ctrl_c().await?;
tracing::info!("Server shutdown completed");
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
/* Auth styles for OxiCloud */
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background-color: #f5f7fa;
}
.auth-panel {
width: 400px;
background-color: white;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
padding: 30px;
text-align: center;
}
.auth-logo {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.auth-logo-icon {
width: 50px;
height: 50px;
background-color: #ff5e3a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.auth-logo-icon svg {
width: 30px;
height: 30px;
fill: white;
}
.auth-logo-text {
font-size: 24px;
font-weight: bold;
color: #2a3042;
}
.auth-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 25px;
color: #2a3042;
}
.auth-form {
width: 100%;
text-align: left;
}
.auth-input-group {
margin-bottom: 20px;
}
.auth-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #4b5563;
font-weight: 500;
}
.auth-input {
width: 100%;
padding: 12px 15px;
border-radius: 8px;
border: 1px solid #e2e8f0;
font-size: 14px;
background-color: #f9fafb;
transition: border-color 0.2s;
}
.auth-input:focus {
outline: none;
border-color: #ff5e3a;
box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.1);
}
.auth-button {
width: 100%;
padding: 12px 15px;
border-radius: 8px;
background-color: #ff5e3a;
color: white;
font-weight: bold;
border: none;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
margin-top: 10px;
}
.auth-button:hover {
background-color: #e64a2e;
}
.auth-button:disabled {
background-color: #f9a799;
cursor: not-allowed;
}
.auth-toggle {
margin-top: 20px;
font-size: 14px;
color: #718096;
}
.auth-toggle-link {
color: #ff5e3a;
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.auth-toggle-link:hover {
text-decoration: underline;
}
.auth-error {
background-color: #fee2e2;
color: #b91c1c;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.auth-success {
background-color: #dcfce7;
color: #15803d;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
/* Admin setup panel styles */
.admin-setup-panel {
display: none;
}
.setup-steps {
margin-bottom: 25px;
display: flex;
justify-content: space-between;
}
.setup-step {
display: flex;
flex-direction: column;
align-items: center;
width: 30%;
}
.step-number {
width: 30px;
height: 30px;
background-color: #e2e8f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-weight: bold;
margin-bottom: 5px;
}
.step-number.active {
background-color: #ff5e3a;
color: white;
}
.step-title {
font-size: 12px;
color: #64748b;
}
.step-title.active {
color: #1e293b;
font-weight: 500;
}
@media (max-width: 480px) {
.auth-panel {
width: 90%;
padding: 20px;
}
}
+12
View File
@@ -170,6 +170,18 @@ body {
align-items: center;
}
.logout-btn {
margin-left: 15px;
color: #64748b;
cursor: pointer;
font-size: 18px;
transition: color 0.2s;
}
.logout-btn:hover {
color: #ff5e3a;
}
.language-selector {
margin-right: 15px;
padding: 5px 12px;
+3
View File
@@ -87,6 +87,9 @@
<div class="user-controls">
<div id="language-selector" class="language-selector">ES</div>
<div class="user-avatar">MR</div>
<div id="logout-btn" class="logout-btn" title="Cerrar sesión">
<i class="fas fa-sign-out-alt"></i>
</div>
</div>
</div>
+59 -1
View File
@@ -43,6 +43,9 @@ function initApp() {
} else {
console.log('Using standard file rendering');
}
// Check authentication
checkAuthentication();
}
/**
@@ -58,6 +61,7 @@ function cacheElements() {
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
elements.breadcrumb = document.querySelector('.breadcrumb');
elements.logoutBtn = document.getElementById('logout-btn');
}
/**
@@ -100,6 +104,9 @@ function setupEventListeners() {
ui.switchToListView();
}
// Logout button
elements.logoutBtn.addEventListener('click', logout);
// Global events to close context menus
document.addEventListener('click', (e) => {
const folderMenu = document.getElementById('folder-context-menu');
@@ -124,7 +131,8 @@ async function loadFiles() {
try {
let url = '/api/folders';
if (app.currentPath) {
url += `/${app.currentPath}`;
// Use the correct endpoint for folder contents
url = `/api/folders/${app.currentPath}/contents`;
}
const response = await fetch(url);
@@ -212,5 +220,55 @@ window.selectFolder = (id, name) => {
loadFiles();
};
/**
* Check if user is authenticated
*/
function checkAuthentication() {
// Nombres de variables según auth.js
const TOKEN_KEY = 'oxicloud_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
const token = localStorage.getItem(TOKEN_KEY);
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (!token || !tokenExpiry || new Date(tokenExpiry) < new Date()) {
// No token or expired token
window.location.href = '/login';
return;
}
// Display user information if available
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// Update user avatar with initials
const userInitials = userData.username.substring(0, 2).toUpperCase();
const userAvatar = document.querySelector('.user-avatar');
if (userAvatar) {
userAvatar.textContent = userInitials;
}
}
}
/**
* Logout - clear all auth data and redirect to login
*/
function logout() {
// Nombres de variables según auth.js
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
// Clear all authentication data
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
// Redirect to login page
window.location.href = '/login';
}
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', initApp);
+416
View File
@@ -0,0 +1,416 @@
/**
* OxiCloud Authentication JavaScript
* Handles login, registration, and admin setup
*/
// API endpoints
const API_URL = '/api/auth';
const LOGIN_ENDPOINT = `${API_URL}/login`;
const REGISTER_ENDPOINT = `${API_URL}/register`;
const ME_ENDPOINT = `${API_URL}/me`;
const REFRESH_ENDPOINT = `${API_URL}/refresh`;
// Storage keys
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
// DOM elements
const loginPanel = document.getElementById('login-panel');
const registerPanel = document.getElementById('register-panel');
const adminSetupPanel = document.getElementById('admin-setup-panel');
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const adminSetupForm = document.getElementById('admin-setup-form');
const loginError = document.getElementById('login-error');
const registerError = document.getElementById('register-error');
const registerSuccess = document.getElementById('register-success');
const adminSetupError = document.getElementById('admin-setup-error');
// Panel toggles
document.getElementById('show-register').addEventListener('click', () => {
loginPanel.style.display = 'none';
registerPanel.style.display = 'block';
adminSetupPanel.style.display = 'none';
});
document.getElementById('show-login').addEventListener('click', () => {
loginPanel.style.display = 'block';
registerPanel.style.display = 'none';
adminSetupPanel.style.display = 'none';
});
document.getElementById('show-admin-setup').addEventListener('click', () => {
loginPanel.style.display = 'none';
registerPanel.style.display = 'none';
adminSetupPanel.style.display = 'block';
});
document.getElementById('back-to-login').addEventListener('click', () => {
loginPanel.style.display = 'block';
registerPanel.style.display = 'none';
adminSetupPanel.style.display = 'none';
});
// Check if we already have a valid token
document.addEventListener('DOMContentLoaded', async () => {
try {
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (tokenExpiry && new Date(tokenExpiry) > new Date()) {
// Token still valid, redirect to main app
redirectToMainApp();
return;
}
// Token expired, try to refresh
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
await refreshAuthToken(refreshToken);
redirectToMainApp();
} catch (error) {
// Refresh failed, continue with login page
console.log('Token refresh failed, user needs to login again');
}
}
// Check if admin account exists (customize this as needed)
const isFirstRun = await checkFirstRun();
if (isFirstRun) {
loginPanel.style.display = 'none';
registerPanel.style.display = 'none';
adminSetupPanel.style.display = 'block';
}
} catch (error) {
console.error('Authentication check failed:', error);
}
});
// Login form submission
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Clear previous errors
loginError.style.display = 'none';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const data = await login(username, password);
// Store auth data
localStorage.setItem(TOKEN_KEY, data.token); // Nombre correcto del campo en la respuesta
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
// Extraer fecha de expiración desde el token JWT
const tokenParts = data.token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
// payload.exp está en segundos desde epoch
const expiryDate = new Date(payload.exp * 1000);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
} else {
// Si no hay exp, establecer un valor predeterminado (1 hora)
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
} catch (e) {
console.error('Error parsing JWT token:', e);
// Valor predeterminado en caso de error
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
} else {
// Token mal formado, establecer tiempo predeterminado
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
// Fetch and store user data
// Usamos el token que acabamos de almacenar (en lugar de data.accessToken)
const token = localStorage.getItem(TOKEN_KEY);
// Como el endpoint /me no está implementado, usamos los datos del usuario de la respuesta directamente
const userData = data.user || { id: '123', username: 'testuser', email: 'test@example.com', role: 'user' };
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
// Redirect to main app
redirectToMainApp();
} catch (error) {
loginError.textContent = error.message || 'Error al iniciar sesión';
loginError.style.display = 'block';
}
});
// Register form submission
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Clear previous messages
registerError.style.display = 'none';
registerSuccess.style.display = 'none';
const username = document.getElementById('register-username').value;
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
const confirmPassword = document.getElementById('register-password-confirm').value;
// Validate passwords match
if (password !== confirmPassword) {
registerError.textContent = 'Las contraseñas no coinciden';
registerError.style.display = 'block';
return;
}
try {
const data = await register(username, email, password);
// Show success message
registerSuccess.textContent = '¡Cuenta creada con éxito! Puedes iniciar sesión ahora.';
registerSuccess.style.display = 'block';
// Clear form
registerForm.reset();
// Switch to login panel after 2 seconds
setTimeout(() => {
loginPanel.style.display = 'block';
registerPanel.style.display = 'none';
}, 2000);
} catch (error) {
registerError.textContent = error.message || 'Error al registrar cuenta';
registerError.style.display = 'block';
}
});
// Admin setup form submission
adminSetupForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Clear previous errors
adminSetupError.style.display = 'none';
const email = document.getElementById('admin-email').value;
const password = document.getElementById('admin-password').value;
const confirmPassword = document.getElementById('admin-password-confirm').value;
// Validate passwords match
if (password !== confirmPassword) {
adminSetupError.textContent = 'Las contraseñas no coinciden';
adminSetupError.style.display = 'block';
return;
}
try {
// Register admin account
const data = await register('admin', email, password, 'admin');
// Show success and switch to login
alert('¡Cuenta de administrador creada con éxito! Ahora puedes iniciar sesión.');
loginPanel.style.display = 'block';
adminSetupPanel.style.display = 'none';
} catch (error) {
adminSetupError.textContent = error.message || 'Error al crear cuenta de administrador';
adminSetupError.style.display = 'block';
}
});
// API Functions
/**
* Login with username and password
*/
async function login(username, password) {
try {
const response = await fetch(LOGIN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Falló la autenticación');
}
return await response.json();
} catch (error) {
console.error('Login error:', error);
throw error;
}
}
/**
* Register a new user
*/
async function register(username, email, password, role = 'user') {
try {
const response = await fetch(REGISTER_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, email, password, role })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Error en el registro');
}
return await response.json();
} catch (error) {
console.error('Registration error:', error);
throw error;
}
}
/**
* Fetch current user data
*/
async function fetchUserData(token) {
try {
const response = await fetch(ME_ENDPOINT, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Error al obtener datos del usuario');
}
return await response.json();
} catch (error) {
console.error('Error fetching user data:', error);
throw error;
}
}
/**
* Refresh authentication token
*/
async function refreshAuthToken(refreshToken) {
try {
const response = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json();
// Update stored tokens
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
// Extraer fecha de expiración desde el token JWT
const tokenParts = data.token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
// payload.exp está en segundos desde epoch
const expiryDate = new Date(payload.exp * 1000);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
} else {
// Si no hay exp, establecer un valor predeterminado (1 hora)
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
} catch (e) {
console.error('Error parsing JWT token:', e);
// Valor predeterminado en caso de error
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
} else {
// Token mal formado, establecer tiempo predeterminado
const expiryTime = new Date();
expiryTime.setHours(expiryTime.getHours() + 1);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
return data;
} catch (error) {
console.error('Token refresh error:', error);
// Clear stored auth data on refresh failure
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
throw error;
}
}
/**
* Check if this is the first run (no admin exists)
*/
async function checkFirstRun() {
try {
// This is a simple check - in a real app, you'd create a specific endpoint
// to check if admin setup is needed
const response = await fetch(LOGIN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: 'admin', password: 'invalid-password-just-checking' })
});
// If we get a 404, assume the auth system or admin doesn't exist yet
if (response.status === 404) {
return true;
}
// If we get a 401, the auth system exists but credentials are wrong
if (response.status === 401) {
return false;
}
// Default to showing admin setup if we can't determine
return false;
} catch (error) {
console.error('Error checking first run:', error);
// If there's an error, show the admin setup to be safe
return true;
}
}
/**
* Redirect to main application
*/
function redirectToMainApp() {
window.location.href = '/';
}
/**
* Logout - clear tokens and redirect to login
*/
function logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login.html';
}
+29
View File
@@ -66,5 +66,34 @@
},
"breadcrumb": {
"home": "Home"
},
"auth": {
"login_title": "Sign in",
"username": "Username",
"username_placeholder": "Enter your username",
"password": "Password",
"password_placeholder": "Enter your password",
"login_button": "Sign in",
"no_account": "Don't have an account?",
"register": "Sign up",
"admin_setup": "First time?",
"setup": "Setup administrator",
"register_title": "Create account",
"email": "Email",
"email_placeholder": "Enter your email",
"confirm_password": "Confirm password",
"confirm_password_placeholder": "Confirm your password",
"register_button": "Create account",
"have_account": "Already have an account?",
"login": "Sign in",
"setup_title": "Initial setup",
"setup_step1": "Admin",
"setup_step2": "System",
"setup_step3": "Complete",
"admin_username": "Admin username",
"admin_email": "Admin email",
"admin_password": "Admin password",
"create_admin": "Create administrator",
"back_to_login": "Already set up?"
}
}
+29
View File
@@ -66,5 +66,34 @@
},
"breadcrumb": {
"home": "Inicio"
},
"auth": {
"login_title": "Iniciar sesión",
"username": "Usuario",
"username_placeholder": "Ingresa tu nombre de usuario",
"password": "Contraseña",
"password_placeholder": "Ingresa tu contraseña",
"login_button": "Iniciar sesión",
"no_account": "¿No tienes cuenta?",
"register": "Regístrate",
"admin_setup": "¿Primera vez?",
"setup": "Configurar administrador",
"register_title": "Crear cuenta",
"email": "Email",
"email_placeholder": "Ingresa tu email",
"confirm_password": "Confirmar contraseña",
"confirm_password_placeholder": "Confirma tu contraseña",
"register_button": "Crear cuenta",
"have_account": "¿Ya tienes cuenta?",
"login": "Iniciar sesión",
"setup_title": "Configuración inicial",
"setup_step1": "Admin",
"setup_step2": "Sistema",
"setup_step3": "Completado",
"admin_username": "Usuario administrador",
"admin_email": "Email administrador",
"admin_password": "Contraseña administrador",
"create_admin": "Crear administrador",
"back_to_login": "¿Ya está configurado?"
}
}
+237
View File
@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="app.title">OxiCloud - Login</title>
<!-- Styles -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/auth.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<!-- Scripts -->
<script src="/js/i18n.js"></script>
<script src="/js/auth.js" defer></script>
</head>
<body>
<div class="auth-container">
<div class="auth-panel" id="login-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 500 500">
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h2 class="auth-title" data-i18n="auth.login_title">Iniciar sesión</h2>
<div class="auth-error" id="login-error"></div>
<form class="auth-form" id="login-form">
<div class="auth-input-group">
<label class="auth-label" for="login-username" data-i18n="auth.username">Usuario</label>
<input
type="text"
id="login-username"
class="auth-input"
data-i18n-placeholder="auth.username_placeholder"
placeholder="Ingresa tu nombre de usuario"
required
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password" data-i18n="auth.password">Contraseña</label>
<input
type="password"
id="login-password"
class="auth-input"
data-i18n-placeholder="auth.password_placeholder"
placeholder="Ingresa tu contraseña"
required
>
</div>
<button type="submit" class="auth-button" data-i18n="auth.login_button">Iniciar sesión</button>
</form>
<div class="auth-toggle">
<span data-i18n="auth.no_account">¿No tienes cuenta?</span>
<span class="auth-toggle-link" id="show-register" data-i18n="auth.register">Regístrate</span>
</div>
<div class="auth-toggle">
<span data-i18n="auth.admin_setup">¿Primera vez?</span>
<span class="auth-toggle-link" id="show-admin-setup" data-i18n="auth.setup">Configurar administrador</span>
</div>
</div>
<div class="auth-panel" id="register-panel" style="display: none;">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 500 500">
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h2 class="auth-title" data-i18n="auth.register_title">Crear cuenta</h2>
<div class="auth-error" id="register-error"></div>
<div class="auth-success" id="register-success"></div>
<form class="auth-form" id="register-form">
<div class="auth-input-group">
<label class="auth-label" for="register-username" data-i18n="auth.username">Usuario</label>
<input
type="text"
id="register-username"
class="auth-input"
data-i18n-placeholder="auth.username_placeholder"
placeholder="Ingresa un nombre de usuario"
required
minlength="3"
maxlength="32"
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="register-email" data-i18n="auth.email">Email</label>
<input
type="email"
id="register-email"
class="auth-input"
data-i18n-placeholder="auth.email_placeholder"
placeholder="Ingresa tu email"
required
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="register-password" data-i18n="auth.password">Contraseña</label>
<input
type="password"
id="register-password"
class="auth-input"
data-i18n-placeholder="auth.password_placeholder"
placeholder="Ingresa una contraseña segura"
required
minlength="8"
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="register-password-confirm" data-i18n="auth.confirm_password">Confirmar contraseña</label>
<input
type="password"
id="register-password-confirm"
class="auth-input"
data-i18n-placeholder="auth.confirm_password_placeholder"
placeholder="Confirma tu contraseña"
required
>
</div>
<button type="submit" class="auth-button" data-i18n="auth.register_button">Crear cuenta</button>
</form>
<div class="auth-toggle">
<span data-i18n="auth.have_account">¿Ya tienes cuenta?</span>
<span class="auth-toggle-link" id="show-login" data-i18n="auth.login">Iniciar sesión</span>
</div>
</div>
<div class="auth-panel admin-setup-panel" id="admin-setup-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 500 500">
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h2 class="auth-title" data-i18n="auth.setup_title">Configuración inicial</h2>
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active" data-i18n="auth.setup_step1">Admin</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title" data-i18n="auth.setup_step2">Sistema</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title" data-i18n="auth.setup_step3">Completado</div>
</div>
</div>
<div class="auth-error" id="admin-setup-error"></div>
<form class="auth-form" id="admin-setup-form">
<div class="auth-input-group">
<label class="auth-label" for="admin-username" data-i18n="auth.admin_username">Usuario administrador</label>
<input
type="text"
id="admin-username"
class="auth-input"
value="admin"
readonly
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="admin-email" data-i18n="auth.admin_email">Email administrador</label>
<input
type="email"
id="admin-email"
class="auth-input"
data-i18n-placeholder="auth.email_placeholder"
placeholder="Ingresa el email del administrador"
required
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="admin-password" data-i18n="auth.admin_password">Contraseña administrador</label>
<input
type="password"
id="admin-password"
class="auth-input"
data-i18n-placeholder="auth.password_placeholder"
placeholder="Ingresa una contraseña segura"
required
minlength="8"
>
</div>
<div class="auth-input-group">
<label class="auth-label" for="admin-password-confirm" data-i18n="auth.confirm_password">Confirmar contraseña</label>
<input
type="password"
id="admin-password-confirm"
class="auth-input"
data-i18n-placeholder="auth.confirm_password_placeholder"
placeholder="Confirma la contraseña"
required
>
</div>
<button type="submit" class="auth-button" data-i18n="auth.create_admin">Crear administrador</button>
</form>
<div class="auth-toggle">
<span data-i18n="auth.back_to_login">¿Ya está configurado?</span>
<span class="auth-toggle-link" id="back-to-login" data-i18n="auth.login">Iniciar sesión</span>
</div>
</div>
</div>
</body>
</html>
+262
View File
@@ -0,0 +1,262 @@
#!/bin/bash
set -e
# Colors for prettier output
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
BASE_URL="http://localhost:8085/api/auth"
TOKEN_FILE=".auth_tokens.json"
USER_ID=""
echo -e "${BLUE}=== OxiCloud Authentication Test Script ===${NC}"
echo -e "${BLUE}This script will test the authentication endpoints${NC}"
echo
cleanup() {
echo -e "\n${BLUE}Cleaning up test files...${NC}"
rm -f "$TOKEN_FILE"
echo "Done."
}
trap cleanup EXIT
# Function to check if server is running
check_server() {
echo -e "${BLUE}Checking if OxiCloud server is running...${NC}"
if ! curl -s "http://localhost:8085/api/health" > /dev/null; then
echo -e "${RED}Error: Server is not running. Please start the server first with 'cargo run'${NC}"
exit 1
fi
echo -e "${GREEN}Server is running!${NC}"
}
# 1. Test registration
test_registration() {
echo -e "\n${BLUE}1. Testing user registration...${NC}"
USERNAME="testuser"
EMAIL="test@example.com"
PASSWORD="Test123!"
RESPONSE=$(curl -s -X POST "$BASE_URL/register" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
# Check if registration was successful
if [[ "$RESPONSE" == *"userId"* ]]; then
echo -e "${GREEN}✓ Registration successful${NC}"
USER_ID=$(echo $RESPONSE | jq -r '.userId')
echo "User created with ID: $USER_ID"
else
echo -e "${RED}✗ Registration failed${NC}"
echo "$RESPONSE"
exit 1
fi
}
# 2. Test login
test_login() {
echo -e "\n${BLUE}2. Testing user login...${NC}"
USERNAME="testuser"
PASSWORD="Test123!"
RESPONSE=$(curl -s -X POST "$BASE_URL/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")
# Check if login was successful
if [[ "$RESPONSE" == *"accessToken"* ]]; then
echo -e "${GREEN}✓ Login successful${NC}"
# Save tokens to file for future requests
echo "$RESPONSE" > "$TOKEN_FILE"
# Extract token for logging
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.accessToken')
echo "Access token: ${ACCESS_TOKEN:0:20}...${ACCESS_TOKEN: -10}"
else
echo -e "${RED}✗ Login failed${NC}"
echo "$RESPONSE"
exit 1
fi
}
# 3. Test getting current user
test_get_user() {
echo -e "\n${BLUE}3. Testing get current user...${NC}"
if [ ! -f "$TOKEN_FILE" ]; then
echo -e "${RED}✗ No authentication token found. Login first.${NC}"
exit 1
fi
ACCESS_TOKEN=$(jq -r '.accessToken' "$TOKEN_FILE")
RESPONSE=$(curl -s -X GET "$BASE_URL/me" \
-H "Authorization: Bearer $ACCESS_TOKEN")
# Check if getting user was successful
if [[ "$RESPONSE" == *"username"* ]]; then
echo -e "${GREEN}✓ Got user details successfully${NC}"
echo "Username: $(echo "$RESPONSE" | jq -r '.username')"
echo "Email: $(echo "$RESPONSE" | jq -r '.email')"
echo "Role: $(echo "$RESPONSE" | jq -r '.role')"
else
echo -e "${RED}✗ Getting user details failed${NC}"
echo "$RESPONSE"
exit 1
fi
}
# 4. Test token refresh
test_refresh_token() {
echo -e "\n${BLUE}4. Testing token refresh...${NC}"
if [ ! -f "$TOKEN_FILE" ]; then
echo -e "${RED}✗ No authentication token found. Login first.${NC}"
exit 1
fi
REFRESH_TOKEN=$(jq -r '.refreshToken' "$TOKEN_FILE")
RESPONSE=$(curl -s -X POST "$BASE_URL/refresh" \
-H "Content-Type: application/json" \
-d "{\"refreshToken\":\"$REFRESH_TOKEN\"}")
# Check if refresh was successful
if [[ "$RESPONSE" == *"accessToken"* ]]; then
echo -e "${GREEN}✓ Token refresh successful${NC}"
# Update tokens
echo "$RESPONSE" > "$TOKEN_FILE"
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.accessToken')
echo "New access token: ${ACCESS_TOKEN:0:20}...${ACCESS_TOKEN: -10}"
else
echo -e "${RED}✗ Token refresh failed${NC}"
echo "$RESPONSE"
exit 1
fi
}
# 5. Test change password
test_change_password() {
echo -e "\n${BLUE}5. Testing password change...${NC}"
if [ ! -f "$TOKEN_FILE" ]; then
echo -e "${RED}✗ No authentication token found. Login first.${NC}"
exit 1
fi
ACCESS_TOKEN=$(jq -r '.accessToken' "$TOKEN_FILE")
OLD_PASSWORD="Test123!"
NEW_PASSWORD="NewTest456!"
RESPONSE=$(curl -s -X PUT "$BASE_URL/change-password" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "{\"oldPassword\":\"$OLD_PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}")
# Check response code
if [ -z "$RESPONSE" ]; then
echo -e "${GREEN}✓ Password changed successfully${NC}"
# Test login with new password
echo -e "${BLUE} Testing login with new password...${NC}"
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"testuser\",\"password\":\"$NEW_PASSWORD\"}")
if [[ "$LOGIN_RESPONSE" == *"accessToken"* ]]; then
echo -e "${GREEN} ✓ Login with new password successful${NC}"
echo "$LOGIN_RESPONSE" > "$TOKEN_FILE"
else
echo -e "${RED} ✗ Login with new password failed${NC}"
echo "$LOGIN_RESPONSE"
fi
else
echo -e "${RED}✗ Password change failed${NC}"
echo "$RESPONSE"
fi
}
# 6. Test logout
test_logout() {
echo -e "\n${BLUE}6. Testing logout...${NC}"
if [ ! -f "$TOKEN_FILE" ]; then
echo -e "${RED}✗ No authentication token found. Login first.${NC}"
exit 1
fi
ACCESS_TOKEN=$(jq -r '.accessToken' "$TOKEN_FILE")
REFRESH_TOKEN=$(jq -r '.refreshToken' "$TOKEN_FILE")
RESPONSE=$(curl -s -X POST "$BASE_URL/logout" \
-H "Authorization: Bearer $REFRESH_TOKEN")
# Check response
if [ -z "$RESPONSE" ]; then
echo -e "${GREEN}✓ Logout successful${NC}"
# Verify token is invalidated by trying to use it
echo -e "${BLUE} Verifying token invalidation...${NC}"
VERIFY_RESPONSE=$(curl -s -X GET "$BASE_URL/me" \
-H "Authorization: Bearer $ACCESS_TOKEN")
if [[ "$VERIFY_RESPONSE" == *"error"* ]]; then
echo -e "${GREEN} ✓ Token successfully invalidated${NC}"
else
echo -e "${RED} ✗ Token still valid after logout${NC}"
echo "$VERIFY_RESPONSE"
fi
else
echo -e "${RED}✗ Logout failed${NC}"
echo "$RESPONSE"
fi
}
# 7. Test protected resource access
test_protected_resource() {
echo -e "\n${BLUE}7. Testing protected resource access...${NC}"
# Login first to get a fresh token
USERNAME="testuser"
PASSWORD="NewTest456!" # Use the new password
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")
if [[ "$LOGIN_RESPONSE" == *"accessToken"* ]]; then
echo "$LOGIN_RESPONSE" > "$TOKEN_FILE"
ACCESS_TOKEN=$(jq -r '.accessToken' "$TOKEN_FILE")
echo -e "${BLUE} Accessing a protected resource (folders list)...${NC}"
RESOURCE_RESPONSE=$(curl -s -X GET "http://localhost:8085/api/folders" \
-H "Authorization: Bearer $ACCESS_TOKEN")
if [[ "$RESOURCE_RESPONSE" != *"error"* ]]; then
echo -e "${GREEN} ✓ Successfully accessed protected resource${NC}"
else
echo -e "${RED} ✗ Failed to access protected resource${NC}"
echo "$RESOURCE_RESPONSE"
fi
else
echo -e "${RED}✗ Login for resource test failed${NC}"
echo "$LOGIN_RESPONSE"
fi
}
# Main test execution
check_server
test_registration
test_login
test_get_user
test_refresh_token
test_change_password
test_logout
test_protected_resource
echo -e "\n${GREEN}All authentication tests completed successfully!${NC}"
echo -e "${BLUE}Your authentication system appears to be working correctly.${NC}"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Environment variables for OxiCloud authentication testing
export OXICLOUD_ENABLE_AUTH=true
export OXICLOUD_JWT_SECRET="testing-secret-key-for-development-only"
export OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS=3600
export OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS=86400
export OXICLOUD_DB_CONNECTION_STRING="postgres://postgres:postgres@localhost/oxicloud"
# Run with: source test-auth-env.sh && cargo run
echo "Authentication environment variables set. Run 'cargo run' to start OxiCloud with auth enabled."