Generated
+16
-95
@@ -86,9 +86,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.21"
|
||||
version = "0.4.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0cf008e5e1a9e9e22a7d3c9a4992e21a350290069e36d8fb72304ed17e8f2d2"
|
||||
checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-core",
|
||||
@@ -151,41 +151,14 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core 0.4.5",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"matchit 0.7.3",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"sync_wrapper",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
|
||||
dependencies = [
|
||||
"axum-core 0.5.0",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -195,7 +168,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit 0.8.4",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
@@ -214,27 +187,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.0"
|
||||
@@ -256,27 +208,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
name = "axum-macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"axum-core 0.4.5",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -412,17 +351,6 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -662,9 +590,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fragile"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa"
|
||||
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
@@ -1319,12 +1247,6 @@ dependencies = [
|
||||
"regex-automata 0.1.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -1544,9 +1466,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.1"
|
||||
version = "1.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
|
||||
checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
@@ -1606,8 +1528,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.8.1",
|
||||
"axum-extra",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"bytes",
|
||||
"chrono",
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@ name = "file_operations"
|
||||
harness = true
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.1", features = ["multipart", "http1", "tokio"] }
|
||||
axum = { version = "0.8.1", features = ["multipart", "http1", "tokio", "macros"] }
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
tokio-util = { version = "0.7.14", features = ["io", "codec"] }
|
||||
tokio-stream = { version = "0.1.17", features = ["fs"] }
|
||||
@@ -39,7 +39,6 @@ jsonwebtoken = "9.3.1"
|
||||
argon2 = "0.5.3"
|
||||
rand_core = { version = "0.6.4", features = ["std"] }
|
||||
time = "0.3.41"
|
||||
axum-extra = { version = "0.9.6", features = ["cookie"] }
|
||||
axum-server = "0.6.0"
|
||||
hyper = { version = "1.6.0", features = ["full"] }
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 🚀 OxiCloud [](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<img src="static/oxicloud-logo.svg" alt="OxiCloud" width="300" />
|
||||
<img src="static/oxicloud-logo.svg" alt="OxiCloud" width="400" />
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
## A lightweight, Rust-powered alternative to NextCloud
|
||||
|
||||
I built OxiCloud because I wanted a simpler, faster file storage solution than existing options. After struggling with NextCloud's performance on my home server, I decided to create something that prioritizes speed and simplicity while still being robust enough for daily use.
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# Documentación Técnica: Sistema de Compartición en OxiCloud
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
La funcionalidad de compartición de archivos y carpetas en OxiCloud permite a los usuarios generar enlaces de acceso para compartir sus recursos con otros usuarios, incluso aquellos sin cuenta en el sistema. La implementación sigue los principios de Arquitectura Hexagonal, manteniendo una clara separación entre dominio, aplicación e infraestructura.
|
||||
|
||||
## Arquitectura y Componentes
|
||||
|
||||
### 1. Entidades de Dominio
|
||||
|
||||
**Share (src/domain/entities/share.rs)**
|
||||
|
||||
La entidad principal que representa un recurso compartido:
|
||||
|
||||
```rust
|
||||
pub struct Share {
|
||||
pub id: String, // Identificador único del enlace
|
||||
pub item_id: String, // ID del archivo o carpeta compartido
|
||||
pub item_type: ShareItemType, // Tipo (File o Folder)
|
||||
pub token: String, // Token único para acceso público
|
||||
pub password_hash: Option<String>, // Hash de contraseña opcional
|
||||
pub expires_at: Option<u64>, // Timestamp de expiración opcional
|
||||
pub permissions: SharePermissions, // Permisos otorgados
|
||||
pub created_at: u64, // Timestamp de creación
|
||||
pub created_by: String, // ID del usuario creador
|
||||
pub access_count: u64, // Contador de accesos
|
||||
}
|
||||
|
||||
pub enum ShareItemType {
|
||||
File,
|
||||
Folder
|
||||
}
|
||||
|
||||
pub struct SharePermissions {
|
||||
pub read: bool, // Permiso de lectura
|
||||
pub write: bool, // Permiso de escritura
|
||||
pub reshare: bool, // Permiso para volver a compartir
|
||||
}
|
||||
```
|
||||
|
||||
La entidad implementa métodos para:
|
||||
- Validar la expiración del enlace
|
||||
- Verificar contraseñas
|
||||
- Incrementar el contador de accesos
|
||||
- Modificar propiedades (permisos, contraseña, expiración)
|
||||
|
||||
### 2. Interfaces del Repositorio
|
||||
|
||||
**ShareRepository (src/domain/repositories/share_repository.rs)**
|
||||
|
||||
Define las operaciones de persistencia para los enlaces compartidos:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ShareRepository: Send + Sync + 'static {
|
||||
async fn save(&self, share: &Share) -> Result<Share, ShareRepositoryError>;
|
||||
async fn find_by_id(&self, id: &str) -> Result<Share, ShareRepositoryError>;
|
||||
async fn find_by_token(&self, token: &str) -> Result<Share, ShareRepositoryError>;
|
||||
async fn find_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, ShareRepositoryError>;
|
||||
async fn update(&self, share: &Share) -> Result<Share, ShareRepositoryError>;
|
||||
async fn delete(&self, id: &str) -> Result<(), ShareRepositoryError>;
|
||||
async fn find_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec<Share>, usize), ShareRepositoryError>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Puertos de Aplicación
|
||||
|
||||
**ShareUseCase y ShareStoragePort (src/application/ports/share_ports.rs)**
|
||||
|
||||
Define las interfaces para la capa de aplicación:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ShareUseCase: Send + Sync + 'static {
|
||||
// Crear un nuevo enlace compartido
|
||||
async fn create_shared_link(&self, user_id: &str, dto: CreateShareDto) -> Result<ShareDto, DomainError>;
|
||||
|
||||
// Obtener un enlace compartido por ID
|
||||
async fn get_shared_link(&self, id: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
// Obtener un enlace compartido por token
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
// Obtener todos los enlaces compartidos para un elemento
|
||||
async fn get_shared_links_for_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<ShareDto>, DomainError>;
|
||||
|
||||
// Actualizar un enlace compartido
|
||||
async fn update_shared_link(&self, id: &str, dto: UpdateShareDto) -> Result<ShareDto, DomainError>;
|
||||
|
||||
// Eliminar un enlace compartido
|
||||
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
// Obtener enlaces compartidos de un usuario con paginación
|
||||
async fn get_user_shared_links(&self, user_id: &str, page: usize, per_page: usize) -> Result<PaginatedResponseDto<ShareDto>, DomainError>;
|
||||
|
||||
// Verificar la contraseña de un enlace protegido
|
||||
async fn verify_shared_link_password(&self, token: &str, password: &str) -> Result<bool, DomainError>;
|
||||
|
||||
// Registrar un acceso a un enlace compartido
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareStoragePort: Send + Sync + 'static {
|
||||
// Métodos para interactuar con el almacenamiento
|
||||
async fn save_share(&self, share: &Share) -> Result<Share, DomainError>;
|
||||
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError>;
|
||||
// ... otros métodos
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Objetos de Transferencia de Datos (DTOs)
|
||||
|
||||
**DTOs (src/application/dtos/share_dto.rs)**
|
||||
|
||||
```rust
|
||||
// DTO para la creación de enlaces compartidos
|
||||
pub struct CreateShareDto {
|
||||
pub item_id: String,
|
||||
pub item_type: String,
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: Option<SharePermissionsDto>,
|
||||
}
|
||||
|
||||
// DTO para actualizar enlaces compartidos
|
||||
pub struct UpdateShareDto {
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: Option<SharePermissionsDto>,
|
||||
}
|
||||
|
||||
// DTO de permisos
|
||||
pub struct SharePermissionsDto {
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
pub reshare: bool,
|
||||
}
|
||||
|
||||
// DTO para respuestas
|
||||
pub struct ShareDto {
|
||||
pub id: String,
|
||||
pub item_id: String,
|
||||
pub item_type: String,
|
||||
pub token: String,
|
||||
pub url: String,
|
||||
pub password_protected: bool,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: SharePermissionsDto,
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
pub access_count: u64,
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Servicios de Aplicación
|
||||
|
||||
**ShareService (src/application/services/share_service.rs)**
|
||||
|
||||
Implementa la lógica de negocio para la compartición de archivos:
|
||||
|
||||
```rust
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
}
|
||||
```
|
||||
|
||||
El servicio implementa:
|
||||
- Validación de elementos compartidos
|
||||
- Gestión de permisos
|
||||
- Generación de enlaces y tokens únicos
|
||||
- Protección con contraseña
|
||||
- Control de expiración
|
||||
- Seguimiento de accesos
|
||||
|
||||
### 6. Implementación de Infraestructura
|
||||
|
||||
**ShareFsRepository (src/infrastructure/repositories/share_fs_repository.rs)**
|
||||
|
||||
Implementa la persistencia de enlaces compartidos usando el sistema de archivos:
|
||||
|
||||
```rust
|
||||
pub struct ShareFsRepository {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
// Almacena los enlaces en un archivo JSON
|
||||
struct ShareRecord {
|
||||
id: String,
|
||||
item_id: String,
|
||||
item_type: String,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
expires_at: Option<u64>,
|
||||
permissions_read: bool,
|
||||
permissions_write: bool,
|
||||
permissions_reshare: bool,
|
||||
created_at: u64,
|
||||
created_by: String,
|
||||
access_count: u64,
|
||||
}
|
||||
```
|
||||
|
||||
La implementación:
|
||||
- Guarda los enlaces compartidos en un archivo JSON
|
||||
- Gestiona consultas y actualizaciones
|
||||
- Proporciona búsqueda por ID, token o usuario
|
||||
- Implementa paginación
|
||||
|
||||
### 7. Controladores API y Rutas
|
||||
|
||||
**Manejadores (src/interfaces/api/handlers/share_handler.rs)**
|
||||
|
||||
```rust
|
||||
// Crear un nuevo enlace compartido
|
||||
pub async fn create_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Json(dto): Json<CreateShareDto>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Obtener un enlace compartido
|
||||
pub async fn get_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Obtener enlaces compartidos de un usuario
|
||||
pub async fn get_user_shares(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Query(query): Query<GetSharesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Actualizar un enlace compartido
|
||||
pub async fn update_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<UpdateShareDto>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Eliminar un enlace compartido
|
||||
pub async fn delete_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Acceder a un elemento compartido a través de su token
|
||||
pub async fn access_shared_item(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(token): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
|
||||
// Verificar la contraseña de un elemento compartido protegido
|
||||
pub async fn verify_shared_item_password(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(token): Path<String>,
|
||||
Json(req): Json<VerifyPasswordRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Implementación...
|
||||
}
|
||||
```
|
||||
|
||||
**Rutas (src/interfaces/api/routes.rs)**
|
||||
|
||||
```rust
|
||||
// Rutas privadas para la gestión de enlaces compartidos
|
||||
let share_router = Router::new()
|
||||
.route("/", post(share_handler::create_shared_link))
|
||||
.route("/", get(share_handler::get_user_shares))
|
||||
.route("/{id}", get(share_handler::get_shared_link))
|
||||
.route("/{id}", put(share_handler::update_shared_link))
|
||||
.route("/{id}", delete(share_handler::delete_shared_link));
|
||||
|
||||
// Rutas públicas para acceder a los enlaces compartidos
|
||||
let public_share_router = Router::new()
|
||||
.route("/{token}", get(share_handler::access_shared_item))
|
||||
.route("/{token}/verify", post(share_handler::verify_shared_item_password));
|
||||
|
||||
// Configuración en el router principal
|
||||
router
|
||||
.nest("/shares", share_router) // API privada: /api/shares/...
|
||||
.nest("/s", public_share_router); // API pública: /api/s/...
|
||||
```
|
||||
|
||||
### 8. Integración en el Sistema
|
||||
|
||||
La funcionalidad de compartición está integrada con:
|
||||
|
||||
1. **Configuración del sistema**: Se puede habilitar/deshabilitar mediante la configuración:
|
||||
```rust
|
||||
pub struct FeaturesConfig {
|
||||
// ...
|
||||
pub enable_file_sharing: bool,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
2. **Inyección de dependencias**: El servicio se instancia en main.rs y se inyecta en las rutas:
|
||||
```rust
|
||||
// Inicializar el repositorio y servicio de compartición
|
||||
let share_service: Option<Arc<dyn ShareUseCase>> = if config.features.enable_file_sharing {
|
||||
let share_repository = Arc::new(ShareFsRepository::new(Arc::new(config.clone())));
|
||||
let share_service = Arc::new(ShareService::new(
|
||||
Arc::new(config.clone()),
|
||||
share_repository,
|
||||
file_repository.clone(),
|
||||
folder_repository.clone()
|
||||
));
|
||||
Some(share_service)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Agregar a los servicios de aplicación
|
||||
let application_services = ApplicationServices {
|
||||
// ...
|
||||
share_service: share_service.clone(),
|
||||
};
|
||||
|
||||
// Configurar las rutas
|
||||
let api_routes = create_api_routes(
|
||||
folder_service,
|
||||
file_service,
|
||||
Some(i18n_service),
|
||||
trash_service,
|
||||
search_service,
|
||||
share_service
|
||||
);
|
||||
```
|
||||
|
||||
## Flujos de Trabajo
|
||||
|
||||
### 1. Creación de un Enlace Compartido
|
||||
|
||||
1. El usuario selecciona un archivo o carpeta para compartir
|
||||
2. El frontend envía una petición POST a `/api/shares/` con los detalles (contraseña opcional, expiración, permisos)
|
||||
3. `ShareService.create_shared_link()` valida los datos y verifica que el elemento existe
|
||||
4. Se genera un token único y una URL de acceso
|
||||
5. El enlace se guarda en el repositorio
|
||||
6. Se devuelve la URL y detalles del enlace compartido
|
||||
|
||||
### 2. Acceso a un Recurso Compartido
|
||||
|
||||
1. El usuario recibe y accede a un enlace compartido (ej: `http://oxicloud.example/api/s/{token}`)
|
||||
2. El backend verifica:
|
||||
- Que el token es válido
|
||||
- Que el enlace no ha expirado
|
||||
- Si está protegido por contraseña
|
||||
3. Si requiere contraseña, se solicita al usuario
|
||||
4. El contador de accesos se incrementa
|
||||
5. Se devuelven los metadatos del recurso compartido para mostrar en la interfaz
|
||||
6. El usuario puede acceder al contenido según los permisos otorgados
|
||||
|
||||
## Seguridad
|
||||
|
||||
### Protección por Contraseña
|
||||
|
||||
- Las contraseñas se almacenan como hashes en lugar de texto plano
|
||||
- El sistema utiliza un hash simple por ahora, pero está diseñado para implementar algoritmos más seguros como bcrypt
|
||||
|
||||
### Control de Expiración
|
||||
|
||||
- Los enlaces pueden configurarse para expirar automáticamente
|
||||
- El sistema verifica la expiración antes de permitir accesos
|
||||
|
||||
### Control de Permisos
|
||||
|
||||
- El sistema implementa un modelo de permisos granular (lectura, escritura, recompartir)
|
||||
- Cada operación valida los permisos antes de permitir la acción
|
||||
|
||||
## Manejo de Errores
|
||||
|
||||
El sistema implementa manejo de errores consistente:
|
||||
|
||||
```rust
|
||||
pub enum ShareServiceError {
|
||||
#[error("Share not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Item not found: {0}")]
|
||||
ItemNotFound(String),
|
||||
|
||||
#[error("Access denied: {0}")]
|
||||
AccessDenied(String),
|
||||
|
||||
#[error("Invalid password: {0}")]
|
||||
InvalidPassword(String),
|
||||
|
||||
#[error("Share expired")]
|
||||
Expired,
|
||||
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(String),
|
||||
|
||||
#[error("Invalid item type: {0}")]
|
||||
InvalidItemType(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
```
|
||||
|
||||
Estos errores se mapean a códigos HTTP apropiados en los controladores:
|
||||
- `NotFound` → HTTP 404 Not Found
|
||||
- `PasswordRequired` → HTTP 401 Unauthorized + metadata
|
||||
- `Expired` → HTTP 410 Gone
|
||||
- `AccessDenied` → HTTP 403 Forbidden
|
||||
- `ValidationError` → HTTP 400 Bad Request
|
||||
|
||||
## Extensibilidad y Futuras Mejoras
|
||||
|
||||
La arquitectura está diseñada para permitir futuras mejoras:
|
||||
|
||||
1. **Notificaciones**: Integración con un sistema de notificaciones para alertar a los usuarios cuando se accede a sus recursos compartidos.
|
||||
|
||||
2. **Registro de Actividad**: Implementación de un registro detallado de actividades para auditar quién accedió a qué recursos y cuándo.
|
||||
|
||||
3. **Límites de Uso**: Establecer límites de uso (número máximo de accesos, ancho de banda) para enlaces compartidos.
|
||||
|
||||
4. **Estadísticas Avanzadas**: Proporcionar métricas detalladas sobre el uso de recursos compartidos.
|
||||
|
||||
5. **Persistencia Alternativa**: La arquitectura permite implementar fácilmente alternativas de almacenamiento (base de datos, servicios en la nube) manteniendo la misma interfaz.
|
||||
|
||||
## Estado Actual
|
||||
|
||||
La funcionalidad de compartición está completamente implementada en el backend y lista para integrarse con el frontend. La característica está habilitada por defecto en la configuración actual.
|
||||
|
||||
## Consideraciones Técnicas
|
||||
|
||||
- **Rendimiento**: El sistema utiliza un enfoque de almacenamiento basado en archivos JSON, lo que es adecuado para un volumen moderado de enlaces compartidos. Para una carga mayor, se recomienda migrar a una base de datos.
|
||||
|
||||
- **Escalabilidad**: El diseño permite escalar horizontalmente la funcionalidad implementando repositorios distribuidos o basados en la nube.
|
||||
|
||||
- **Mantenimiento**: La clara separación de responsabilidades facilita el mantenimiento y las pruebas de la funcionalidad.
|
||||
|
||||
## Conclusión
|
||||
|
||||
La implementación del sistema de compartición en OxiCloud sigue los principios de la Arquitectura Hexagonal, permitiendo una clara separación entre el dominio, la aplicación y la infraestructura. Esto facilita la evolución del sistema y la adaptación a requisitos cambiantes. La funcionalidad proporciona todas las características básicas esperadas de un sistema de compartición moderno, incluyendo protección por contraseña, expiración y permisos granulares.
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script is used to apply all migrations in order
|
||||
# It can be run manually or as part of container initialization
|
||||
|
||||
# Determine run mode
|
||||
if [ -z "$POSTGRES_DB" ]; then
|
||||
# Manual mode - script was run from command line
|
||||
|
||||
# Verify we are in the right environment
|
||||
if [ ! -f "Cargo.toml" ]; then
|
||||
echo "Error: This script must be run from the OxiCloud project root directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if postgres container is running
|
||||
POSTGRES_ID=$(docker-compose ps -q postgres 2>/dev/null)
|
||||
if [ -z "$POSTGRES_ID" ]; then
|
||||
echo "Error: PostgreSQL container is not running. Start it with 'docker-compose up -d postgres'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set parameters for manual mode
|
||||
DB_NAME="oxicloud"
|
||||
MIGRATIONS_DIR="./migrations"
|
||||
MIGRATIONS_CMD="docker-compose exec -T postgres psql -U postgres"
|
||||
else
|
||||
# Auto mode - script is running inside postgres container during initialization
|
||||
echo "Running in PostgreSQL container initialization mode"
|
||||
|
||||
# Set parameters for auto mode
|
||||
DB_NAME="$POSTGRES_DB"
|
||||
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
|
||||
MIGRATIONS_CMD="psql -U $POSTGRES_USER"
|
||||
fi
|
||||
|
||||
echo "Applying migrations from $MIGRATIONS_DIR to database $DB_NAME"
|
||||
|
||||
# Ensure schema is created (fallback)
|
||||
$MIGRATIONS_CMD -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS auth;" 2>/dev/null
|
||||
|
||||
# Get each SQL file from migrations directory in alphabetical order
|
||||
for sql_file in $(find $MIGRATIONS_DIR -name "*.sql" | sort); do
|
||||
echo "Applying migration: $(basename $sql_file)"
|
||||
|
||||
# Run the migration
|
||||
if [ -z "$POSTGRES_DB" ]; then
|
||||
# Manual mode - run through docker-compose
|
||||
docker-compose exec -T postgres psql -U postgres -d "$DB_NAME" -f "/docker-entrypoint-initdb.d/migrations/$(basename $sql_file)"
|
||||
else
|
||||
# Auto mode - run directly
|
||||
psql -U "$POSTGRES_USER" -d "$DB_NAME" -f "$sql_file"
|
||||
fi
|
||||
|
||||
# Check if migration was successful
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to apply migration $sql_file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All migrations applied successfully"
|
||||
+67
-24
@@ -1,12 +1,27 @@
|
||||
-- OxiCloud Authentication Database Schema
|
||||
|
||||
-- Create schema for auth-related tables
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Create UserRole enum type
|
||||
DO $BODY$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'userrole' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.userrole AS ENUM ('admin', 'user');
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
CREATE TABLE IF NOT EXISTS auth.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')),
|
||||
password_hash TEXT NOT NULL,
|
||||
role auth.userrole NOT NULL,
|
||||
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,
|
||||
@@ -15,40 +30,47 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Create indexes for users table
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON auth.users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email);
|
||||
|
||||
-- Sessions table for refresh tokens
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
refresh_token VARCHAR(255) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
refresh_token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
ip_address VARCHAR(45), -- to support IPv6
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (user_id, refresh_token)
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Create indexes for sessions table
|
||||
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 INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked, expires_at)
|
||||
WHERE NOT revoked AND expires_at > NOW();
|
||||
|
||||
-- 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,
|
||||
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 TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, path)
|
||||
UNIQUE(user_id, file_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 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);
|
||||
|
||||
-- Create admin user (password: Admin123!)
|
||||
INSERT INTO users (
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
@@ -62,4 +84,25 @@ INSERT INTO users (
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$H3VxE8LL2qPT31DM3loTg6D+O4MSc2sD7GjlQ5h7Jkw', -- Admin123!
|
||||
'admin',
|
||||
107374182400 -- 100GB for admin
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Create test user (password: test123)
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes
|
||||
) VALUES (
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'test',
|
||||
'test@oxicloud.local',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$ZG17Z7SFKhs9zWYbuk08CkHpyiznnZapYnxN5Vi62R4', -- test123
|
||||
'user',
|
||||
10737418240 -- 10GB for test user
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
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';
|
||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/10-schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
-- First create the schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Output diagnostic information
|
||||
\echo 'Starting migration fix for auth.userrole'
|
||||
\echo 'Current schemas:'
|
||||
\dt auth.*
|
||||
\echo 'Current types:'
|
||||
SELECT n.nspname AS schema, t.typname AS type
|
||||
FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'auth';
|
||||
\echo '==============================='
|
||||
|
||||
-- Check if the type already exists and create it if not
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'userrole' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
-- Create the type
|
||||
CREATE TYPE auth.userrole AS ENUM ('admin', 'user');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Check if the users table exists and create it if not
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'auth' AND table_name = 'users'
|
||||
) THEN
|
||||
-- Create the users table with the proper enum type
|
||||
CREATE TABLE 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 auth.userrole 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
|
||||
);
|
||||
ELSE
|
||||
-- Check if the role column is already auth.userrole type
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'auth' AND table_name = 'users'
|
||||
AND column_name = 'role' AND data_type <> 'USER-DEFINED'
|
||||
) THEN
|
||||
-- Try to convert the role column to the new enum type
|
||||
BEGIN
|
||||
ALTER TABLE auth.users ALTER COLUMN role TYPE auth.userrole USING
|
||||
CASE WHEN role = 'admin' THEN 'admin'::auth.userrole
|
||||
WHEN role = 'user' THEN 'user'::auth.userrole
|
||||
ELSE 'user'::auth.userrole END;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Error converting role column: %', SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -1,65 +0,0 @@
|
||||
-- Create the auth schema
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Create UserRole enum type
|
||||
CREATE TYPE auth.userrole AS ENUM ('admin', 'user');
|
||||
|
||||
-- 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 auth.userrole 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';
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Fix the missing UserRole enum type
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if the type already exists
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'userrole' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
-- Create the type if it doesn't exist
|
||||
CREATE TYPE auth.userrole AS ENUM ('admin', 'user');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- If the table already exists but has a different role column type,
|
||||
-- we need to update it to use the new enum type
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if the users table exists
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'auth' AND table_name = 'users'
|
||||
) THEN
|
||||
-- Try to convert the role column to the new enum type
|
||||
-- This will work if the column currently contains 'admin' or 'user' values
|
||||
BEGIN
|
||||
ALTER TABLE auth.users ALTER COLUMN role TYPE auth.userrole USING
|
||||
CASE WHEN role = 'admin' THEN 'admin'::auth.userrole
|
||||
WHEN role = 'user' THEN 'user'::auth.userrole
|
||||
ELSE 'user'::auth.userrole END;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Error converting role column: %', SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -5,4 +5,5 @@ pub mod pagination;
|
||||
pub mod user_dto;
|
||||
pub mod trash_dto;
|
||||
pub mod search_dto;
|
||||
pub mod share_dto;
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::entities::share::{Share, ShareItemType, SharePermissions};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareDto {
|
||||
pub id: String,
|
||||
pub item_id: String,
|
||||
pub item_type: String,
|
||||
pub token: String,
|
||||
pub url: String,
|
||||
pub has_password: bool,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: SharePermissionsDto,
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
pub access_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharePermissionsDto {
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
pub reshare: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateShareDto {
|
||||
pub item_id: String,
|
||||
pub item_type: String,
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: Option<SharePermissionsDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateShareDto {
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: Option<SharePermissionsDto>,
|
||||
}
|
||||
|
||||
/// Extension methods to convert between DTOs and domain entities
|
||||
impl ShareDto {
|
||||
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
||||
let url = format!("{}/s/{}", base_url, share.token);
|
||||
|
||||
Self {
|
||||
id: share.id.clone(),
|
||||
item_id: share.item_id.clone(),
|
||||
item_type: share.item_type.to_string(),
|
||||
token: share.token.clone(),
|
||||
url,
|
||||
has_password: share.password_hash.is_some(),
|
||||
expires_at: share.expires_at,
|
||||
permissions: SharePermissionsDto::from_entity(&share.permissions),
|
||||
created_at: share.created_at,
|
||||
created_by: share.created_by.clone(),
|
||||
access_count: share.access_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SharePermissionsDto {
|
||||
pub fn from_entity(permissions: &SharePermissions) -> Self {
|
||||
Self {
|
||||
read: permissions.read,
|
||||
write: permissions.write,
|
||||
reshare: permissions.reshare,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_entity(&self) -> SharePermissions {
|
||||
SharePermissions::new(self.read, self.write, self.reshare)
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ pub mod outbound;
|
||||
pub mod file_ports;
|
||||
pub mod storage_ports;
|
||||
pub mod auth_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod share_ports;
|
||||
@@ -0,0 +1,85 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
application::dtos::{
|
||||
pagination::PaginatedResponseDto,
|
||||
share_dto::{CreateShareDto, ShareDto, UpdateShareDto}
|
||||
},
|
||||
common::errors::DomainError,
|
||||
domain::entities::share::ShareItemType,
|
||||
};
|
||||
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareUseCase: Send + Sync + 'static {
|
||||
/// Create a new shared link for a file or folder
|
||||
async fn create_shared_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
dto: CreateShareDto,
|
||||
) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Get a shared link by its ID
|
||||
async fn get_shared_link(&self, id: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Get a shared link by its token (for access by non-users)
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Get all shared links for a specific item
|
||||
async fn get_shared_links_for_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<Vec<ShareDto>, DomainError>;
|
||||
|
||||
/// Update a shared link
|
||||
async fn update_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: UpdateShareDto,
|
||||
) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Delete a shared link
|
||||
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Get all shared links created by a specific user
|
||||
async fn get_user_shared_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
page: usize,
|
||||
per_page: usize,
|
||||
) -> Result<PaginatedResponseDto<ShareDto>, DomainError>;
|
||||
|
||||
/// Verify a password for a password-protected shared link
|
||||
async fn verify_shared_link_password(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
/// Register an access to a shared link
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareStoragePort: Send + Sync + 'static {
|
||||
async fn save_share(&self, share: &crate::domain::entities::share::Share)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_id(&self, id: &str)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_token(&self, token: &str)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType)
|
||||
-> Result<Vec<crate::domain::entities::share::Share>, DomainError>;
|
||||
|
||||
async fn update_share(&self, share: &crate::domain::entities::share::Share)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn delete_share(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize)
|
||||
-> Result<(Vec<crate::domain::entities::share::Share>, usize), DomainError>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod file_use_case_factory;
|
||||
pub mod auth_application_service;
|
||||
pub mod trash_service;
|
||||
pub mod search_service;
|
||||
pub mod share_service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trash_service_test;
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
application::{
|
||||
dtos::{
|
||||
pagination::PaginatedResponseDto,
|
||||
share_dto::{CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto},
|
||||
},
|
||||
ports::{
|
||||
outbound::{FileStoragePort, FolderStoragePort},
|
||||
share_ports::{ShareStoragePort, ShareUseCase},
|
||||
},
|
||||
},
|
||||
common::{config::AppConfig, errors::DomainError},
|
||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ShareServiceError {
|
||||
#[error("Share not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Item not found: {0}")]
|
||||
ItemNotFound(String),
|
||||
#[error("Access denied: {0}")]
|
||||
AccessDenied(String),
|
||||
#[error("Invalid password: {0}")]
|
||||
InvalidPassword(String),
|
||||
#[error("Share expired")]
|
||||
Expired,
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(String),
|
||||
#[error("Invalid item type: {0}")]
|
||||
InvalidItemType(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl From<ShareServiceError> for DomainError {
|
||||
fn from(error: ShareServiceError) -> Self {
|
||||
match error {
|
||||
ShareServiceError::NotFound(s) => DomainError::not_found("Share", s),
|
||||
ShareServiceError::ItemNotFound(s) => DomainError::not_found("Item", s),
|
||||
ShareServiceError::AccessDenied(s) => DomainError::access_denied("Share", s),
|
||||
ShareServiceError::InvalidPassword(s) => DomainError::access_denied("Share", s),
|
||||
ShareServiceError::Expired => DomainError::access_denied("Share", "Share has expired".to_string()),
|
||||
ShareServiceError::Repository(s) => DomainError::internal_error("Share", s),
|
||||
ShareServiceError::InvalidItemType(s) => DomainError::validation_error("Share", s),
|
||||
ShareServiceError::Validation(s) => DomainError::validation_error("Share", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica que el elemento a compartir existe
|
||||
async fn verify_item_exists(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<(), ShareServiceError> {
|
||||
match item_type {
|
||||
ShareItemType::File => {
|
||||
self.file_repository
|
||||
.get_file(item_id) // Usando el método correcto del trait FileStoragePort
|
||||
.await
|
||||
.map_err(|_| ShareServiceError::ItemNotFound(format!("File with ID {} not found", item_id)))?;
|
||||
}
|
||||
ShareItemType::Folder => {
|
||||
self.folder_repository
|
||||
.get_folder(item_id) // Usando el método correcto del trait FolderStoragePort
|
||||
.await
|
||||
.map_err(|_| ShareServiceError::ItemNotFound(format!("Folder with ID {} not found", item_id)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash de contraseña
|
||||
fn hash_password(&self, password: &str) -> String {
|
||||
// En una implementación real, usar un algoritmo seguro como bcrypt
|
||||
// Para simplificar, solo devolvemos la misma contraseña
|
||||
password.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShareUseCase for ShareService {
|
||||
async fn create_shared_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
dto: CreateShareDto,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
// Convertir el tipo de elemento
|
||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||
|
||||
// Verificar que el elemento existe
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// Convertir el DTO de permisos si existe
|
||||
let permissions = dto.permissions.map(|p| p.to_entity());
|
||||
|
||||
// Hash de contraseña si existe
|
||||
let password_hash = dto.password.map(|p| self.hash_password(&p));
|
||||
|
||||
// Crear la entidad Share
|
||||
let share = Share::new(
|
||||
dto.item_id.clone(),
|
||||
item_type,
|
||||
user_id.to_string(),
|
||||
permissions,
|
||||
password_hash,
|
||||
dto.expires_at,
|
||||
)
|
||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||
|
||||
// Guardar en el repositorio
|
||||
let saved_share = self
|
||||
.share_repository
|
||||
.save_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Convertir la entidad a DTO para la respuesta
|
||||
Ok(ShareDto::from_entity(&saved_share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
}
|
||||
|
||||
async fn get_shared_link(&self, id: &str) -> Result<ShareDto, DomainError> {
|
||||
// Buscar el enlace compartido por su ID
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
|
||||
|
||||
// Verificar si ha expirado
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
|
||||
// Convertir la entidad a DTO para la respuesta
|
||||
Ok(ShareDto::from_entity(&share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
// Buscar el enlace compartido por su token
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
|
||||
// Verificar si ha expirado
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
|
||||
// Convertir la entidad a DTO para la respuesta
|
||||
Ok(ShareDto::from_entity(&share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
}
|
||||
|
||||
async fn get_shared_links_for_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<Vec<ShareDto>, DomainError> {
|
||||
// Buscar todos los enlaces compartidos para el elemento
|
||||
let shares = self
|
||||
.share_repository
|
||||
.find_shares_by_item(item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Filtrar los enlaces expirados
|
||||
let active_shares: Vec<Share> = shares.into_iter().filter(|s| !s.is_expired()).collect();
|
||||
|
||||
// Convertir las entidades a DTOs para la respuesta
|
||||
let share_dtos = active_shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
.collect();
|
||||
|
||||
Ok(share_dtos)
|
||||
}
|
||||
|
||||
async fn update_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: UpdateShareDto,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
// Buscar el enlace compartido existente
|
||||
let mut share = self
|
||||
.share_repository
|
||||
.find_share_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
|
||||
|
||||
// Actualizar permisos si se proporcionan
|
||||
if let Some(permissions_dto) = dto.permissions {
|
||||
let permissions = SharePermissions::new(
|
||||
permissions_dto.read,
|
||||
permissions_dto.write,
|
||||
permissions_dto.reshare,
|
||||
);
|
||||
share = share.with_permissions(permissions);
|
||||
}
|
||||
|
||||
// Actualizar contraseña si se proporciona
|
||||
if let Some(password) = dto.password {
|
||||
let password_hash = if password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.hash_password(&password))
|
||||
};
|
||||
share = share.with_password(password_hash);
|
||||
}
|
||||
|
||||
// Actualizar fecha de expiración si se proporciona
|
||||
if dto.expires_at.is_some() {
|
||||
share = share.with_expiration(dto.expires_at);
|
||||
}
|
||||
|
||||
// Guardar los cambios
|
||||
let updated_share = self
|
||||
.share_repository
|
||||
.update_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Convertir la entidad a DTO para la respuesta
|
||||
Ok(ShareDto::from_entity(&updated_share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
}
|
||||
|
||||
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError> {
|
||||
// Eliminar el enlace compartido
|
||||
self.share_repository
|
||||
.delete_share(id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_shared_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
page: usize,
|
||||
per_page: usize,
|
||||
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
|
||||
// Calcular offset para paginación
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
// Buscar los enlaces compartidos del usuario
|
||||
let (shares, total) = self
|
||||
.share_repository
|
||||
.find_shares_by_user(user_id, offset, per_page)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Convertir las entidades a DTOs
|
||||
let share_dtos: Vec<ShareDto> = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
|
||||
.collect();
|
||||
|
||||
// Crear el resultado paginado
|
||||
let paginated = PaginatedResponseDto::new(
|
||||
share_dtos,
|
||||
page,
|
||||
per_page,
|
||||
total
|
||||
);
|
||||
|
||||
Ok(paginated)
|
||||
}
|
||||
|
||||
async fn verify_shared_link_password(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
// Buscar el enlace compartido por su token
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
|
||||
// Verificar si ha expirado
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
|
||||
// Verificar la contraseña
|
||||
Ok(share.verify_password(password))
|
||||
}
|
||||
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||
// Buscar el enlace compartido por su token
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
|
||||
// Verificar si ha expirado
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
|
||||
// Incrementar el contador de accesos
|
||||
let updated_share = share.increment_access_count();
|
||||
|
||||
// Guardar los cambios
|
||||
self.share_repository
|
||||
.update_share(&updated_share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::share_ports::ShareStoragePort;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockFileRepository;
|
||||
struct MockFolderRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FileStoragePort for MockFileRepository {
|
||||
async fn find_file_by_id(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
if id == "test_file_id" {
|
||||
let file = crate::domain::entities::file::File::new(
|
||||
id.to_string(),
|
||||
"test.txt".to_string(),
|
||||
"/path/to/test.txt".to_string(),
|
||||
"/test.txt".to_string(),
|
||||
123,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
Ok(file)
|
||||
} else {
|
||||
Err(DomainError::NotFound(format!("File {} not found", id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Implementación dummy para el resto de métodos requeridos
|
||||
async fn find_files_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_file(&self, _file: &crate::domain::entities::file::File) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_files(&self) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderStoragePort for MockFolderRepository {
|
||||
async fn find_folder_by_id(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
if id == "test_folder_id" {
|
||||
let folder = crate::domain::entities::folder::Folder::new(
|
||||
id.to_string(),
|
||||
"test".to_string(),
|
||||
"/path/to/test".to_string(),
|
||||
"/test".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
Ok(folder)
|
||||
} else {
|
||||
Err(DomainError::NotFound(format!("Folder {} not found", id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Implementación dummy para el resto de métodos requeridos
|
||||
async fn find_folders_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_folder(&self, _folder: &crate::domain::entities::folder::Folder) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_folders(&self) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct MockShareRepository {
|
||||
shares: Mutex<HashMap<String, Share>>,
|
||||
tokens: Mutex<HashMap<String, String>>, // token -> id mapping
|
||||
}
|
||||
|
||||
impl MockShareRepository {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
shares: Mutex::new(HashMap::new()),
|
||||
tokens: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShareStoragePort for MockShareRepository {
|
||||
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
|
||||
shares.insert(share.id.clone(), share.clone());
|
||||
tokens.insert(share.token.clone(), share.id.clone());
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
shares.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
|
||||
}
|
||||
|
||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||
let tokens = self.tokens.lock().unwrap();
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let id = tokens.get(token)
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with token {} not found", token)))?;
|
||||
|
||||
shares.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
|
||||
}
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let type_str = item_type.to_string();
|
||||
let result: Vec<Share> = shares.values()
|
||||
.filter(|s| s.item_id == item_id && s.item_type.to_string() == type_str)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
|
||||
if !shares.contains_key(&share.id) {
|
||||
return Err(DomainError::NotFound(format!("Share with ID {} not found for update", share.id)));
|
||||
}
|
||||
|
||||
shares.insert(share.id.clone(), share.clone());
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
|
||||
// Find the share to get the token
|
||||
let share = shares.get(id)
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found for deletion", id)))?;
|
||||
|
||||
// Remove token mapping
|
||||
tokens.remove(&share.token);
|
||||
|
||||
// Remove the share
|
||||
shares.remove(id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec<Share>, usize), DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let user_shares: Vec<Share> = shares.values()
|
||||
.filter(|s| s.created_by == user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let total = user_shares.len();
|
||||
|
||||
// Apply pagination
|
||||
let paginated = user_shares.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect();
|
||||
|
||||
Ok((paginated, total))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_shared_link() {
|
||||
let config = Arc::new(Config {
|
||||
base_url: "http://localhost:8085".to_string(),
|
||||
storage_path: "/tmp/storage".to_string(),
|
||||
log_level: "info".to_string(),
|
||||
port: 8085,
|
||||
database_url: "".to_string(),
|
||||
jwt_secret: "test_secret".to_string(),
|
||||
jwt_expiration: 3600,
|
||||
enable_cors: false,
|
||||
cors_origins: vec![],
|
||||
});
|
||||
|
||||
let share_repo = Arc::new(MockShareRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository);
|
||||
let folder_repo = Arc::new(MockFolderRepository);
|
||||
|
||||
let service = ShareService::new(config, share_repo, file_repo, folder_repo);
|
||||
|
||||
// Test creating a file share
|
||||
let dto = CreateShareDto {
|
||||
item_id: "test_file_id".to_string(),
|
||||
item_type: "file".to_string(),
|
||||
password: Some("secret".to_string()),
|
||||
expires_at: None,
|
||||
permissions: Some(SharePermissionsDto {
|
||||
read: true,
|
||||
write: false,
|
||||
reshare: false,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = service.create_shared_link("user123", dto).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let share_dto = result.unwrap();
|
||||
assert_eq!(share_dto.item_id, "test_file_id");
|
||||
assert_eq!(share_dto.item_type, "file");
|
||||
assert!(share_dto.has_password);
|
||||
assert!(share_dto.url.starts_with("http://localhost:8085/s/"));
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -281,7 +281,7 @@ impl Default for FeaturesConfig {
|
||||
Self {
|
||||
enable_auth: true, // Enable authentication by default
|
||||
enable_user_storage_quotas: false,
|
||||
enable_file_sharing: false,
|
||||
enable_file_sharing: true, // Enable file sharing by default
|
||||
enable_trash: true, // Enable trash feature
|
||||
enable_search: true, // Enable search feature
|
||||
}
|
||||
@@ -412,6 +412,27 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(enable_file_sharing) = env::var("OXICLOUD_ENABLE_FILE_SHARING")
|
||||
.map(|v| v.parse::<bool>()) {
|
||||
if let Ok(val) = enable_file_sharing {
|
||||
config.features.enable_file_sharing = val;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(enable_trash) = env::var("OXICLOUD_ENABLE_TRASH")
|
||||
.map(|v| v.parse::<bool>()) {
|
||||
if let Ok(val) = enable_trash {
|
||||
config.features.enable_trash = val;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH")
|
||||
.map(|v| v.parse::<bool>()) {
|
||||
if let Ok(val) = enable_search {
|
||||
config.features.enable_search = val;
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,7 @@ impl AppServiceFactory {
|
||||
i18n_service,
|
||||
trash_service,
|
||||
search_service,
|
||||
share_service: None, // No share service by default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +282,7 @@ pub struct ApplicationServices {
|
||||
pub i18n_service: Arc<I18nApplicationService>,
|
||||
pub trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
pub search_service: Option<Arc<dyn SearchUseCase>>,
|
||||
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
|
||||
}
|
||||
|
||||
/// Contenedor para servicios de autenticación
|
||||
@@ -300,6 +302,7 @@ pub struct AppState {
|
||||
pub db_pool: Option<Arc<PgPool>>,
|
||||
pub auth_service: Option<AuthServices>,
|
||||
pub trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -768,6 +771,7 @@ impl Default for AppState {
|
||||
i18n_service: Arc::new(DummyI18nApplicationService::dummy()),
|
||||
trash_service: None, // No trash service in minimal mode
|
||||
search_service: Some(Arc::new(DummySearchUseCase) as Arc<dyn crate::application::ports::inbound::SearchUseCase>),
|
||||
share_service: None, // No share service in minimal mode
|
||||
};
|
||||
|
||||
// Return a minimal app state
|
||||
@@ -778,6 +782,7 @@ impl Default for AppState {
|
||||
db_pool: None,
|
||||
auth_service: None,
|
||||
trash_service: None,
|
||||
share_service: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,6 +800,7 @@ impl AppState {
|
||||
db_pool: None,
|
||||
auth_service: None,
|
||||
trash_service: None,
|
||||
share_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,4 +818,9 @@ impl AppState {
|
||||
self.trash_service = Some(trash_service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_share_service(mut self, share_service: Arc<dyn crate::application::ports::share_ports::ShareUseCase>) -> Self {
|
||||
self.share_service = Some(share_service);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod file;
|
||||
pub mod folder;
|
||||
pub mod user;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
pub mod trashed_item;
|
||||
@@ -0,0 +1,244 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Share {
|
||||
pub id: String,
|
||||
pub item_id: String,
|
||||
pub item_type: ShareItemType,
|
||||
pub token: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub permissions: SharePermissions,
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
pub access_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SharePermissions {
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
pub reshare: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ShareItemType {
|
||||
File,
|
||||
Folder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ShareError {
|
||||
#[error("Invalid token: {0}")]
|
||||
InvalidToken(String),
|
||||
#[error("Invalid expiration date: {0}")]
|
||||
InvalidExpiration(String),
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
}
|
||||
|
||||
impl Share {
|
||||
pub fn new(
|
||||
item_id: String,
|
||||
item_type: ShareItemType,
|
||||
created_by: String,
|
||||
permissions: Option<SharePermissions>,
|
||||
password_hash: Option<String>,
|
||||
expires_at: Option<u64>,
|
||||
) -> Result<Self, ShareError> {
|
||||
// Validate item_id
|
||||
if item_id.is_empty() {
|
||||
return Err(ShareError::ValidationError("Item ID cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
// Validate expiration date if provided
|
||||
if let Some(expires) = expires_at {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
if expires <= now {
|
||||
return Err(ShareError::InvalidExpiration("Expiration date must be in the future".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
item_id,
|
||||
item_type,
|
||||
token: Uuid::new_v4().to_string(),
|
||||
password_hash,
|
||||
expires_at,
|
||||
permissions: permissions.unwrap_or(SharePermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
reshare: false,
|
||||
}),
|
||||
created_at: now,
|
||||
created_by,
|
||||
access_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, permissions: SharePermissions) -> Self {
|
||||
self.permissions = permissions;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
|
||||
self.password_hash = password_hash;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_expiration(mut self, expires_at: Option<u64>) -> Self {
|
||||
self.expires_at = expires_at;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_token(mut self, token: String) -> Self {
|
||||
self.token = token;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if let Some(expires_at) = self.expires_at {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
return expires_at <= now;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn increment_access_count(mut self) -> Self {
|
||||
self.access_count += 1;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn verify_password(&self, password: &str) -> bool {
|
||||
match &self.password_hash {
|
||||
Some(hash) => {
|
||||
// In a real implementation, use a proper password hashing function like bcrypt
|
||||
// For simplicity, we're just comparing strings here
|
||||
hash == password
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SharePermissions {
|
||||
pub fn new(read: bool, write: bool, reshare: bool) -> Self {
|
||||
Self {
|
||||
read,
|
||||
write,
|
||||
reshare,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for ShareItemType {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
ShareItemType::File => "file".to_string(),
|
||||
ShareItemType::Folder => "folder".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ShareItemType {
|
||||
type Error = ShareError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"file" => Ok(ShareItemType::File),
|
||||
"folder" => Ok(ShareItemType::Folder),
|
||||
_ => Err(ShareError::ValidationError(format!("Invalid item type: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_share() {
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(share.item_id, "test_file_id");
|
||||
assert_eq!(share.item_type, ShareItemType::File);
|
||||
assert_eq!(share.created_by, "user123");
|
||||
assert_eq!(share.permissions.read, true);
|
||||
assert_eq!(share.permissions.write, false);
|
||||
assert_eq!(share.permissions.reshare, false);
|
||||
assert!(share.password_hash.is_none());
|
||||
assert!(share.expires_at.is_none());
|
||||
assert_eq!(share.access_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_share_is_expired() {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
// Create a share that expires in the future
|
||||
let future = now + 3600; // 1 hour in the future
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(future),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!share.is_expired());
|
||||
|
||||
// Test with past expiration (should fail during creation)
|
||||
let past = now - 3600; // 1 hour in the past
|
||||
let share_result = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(past),
|
||||
);
|
||||
|
||||
assert!(share_result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_share_item_type_conversion() {
|
||||
assert_eq!(ShareItemType::File.to_string(), "file");
|
||||
assert_eq!(ShareItemType::Folder.to_string(), "folder");
|
||||
|
||||
assert_eq!(ShareItemType::try_from("file").unwrap(), ShareItemType::File);
|
||||
assert_eq!(ShareItemType::try_from("folder").unwrap(), ShareItemType::Folder);
|
||||
assert_eq!(ShareItemType::try_from("FILE").unwrap(), ShareItemType::File);
|
||||
assert!(ShareItemType::try_from("invalid").is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod file_repository;
|
||||
pub mod folder_repository;
|
||||
pub mod user_repository;
|
||||
pub mod session_repository;
|
||||
pub mod share_repository;
|
||||
pub mod trash_repository;
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::{
|
||||
entities::share::{Share, ShareItemType},
|
||||
repositories::user_repository::UserRepositoryError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ShareRepositoryError {
|
||||
#[error("Share not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Item not found: {0}")]
|
||||
ItemNotFound(String),
|
||||
#[error("Storage error: {0}")]
|
||||
StorageError(String),
|
||||
#[error("User repository error: {0}")]
|
||||
UserRepository(#[from] UserRepositoryError),
|
||||
#[error("Share already exists: {0}")]
|
||||
AlreadyExists(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareRepository: Send + Sync + 'static {
|
||||
/// Save a new share or update an existing one
|
||||
async fn save(&self, share: &Share) -> Result<Share, ShareRepositoryError>;
|
||||
|
||||
/// Find a share by its ID
|
||||
async fn find_by_id(&self, id: &str) -> Result<Share, ShareRepositoryError>;
|
||||
|
||||
/// Find a share by its token
|
||||
async fn find_by_token(&self, token: &str) -> Result<Share, ShareRepositoryError>;
|
||||
|
||||
/// Find all shares for a specific item
|
||||
async fn find_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, ShareRepositoryError>;
|
||||
|
||||
/// Delete a share by its ID
|
||||
async fn delete(&self, id: &str) -> Result<(), ShareRepositoryError>;
|
||||
|
||||
/// Find all shares created by a specific user
|
||||
async fn find_by_user(&self, user_id: &str) -> Result<Vec<Share>, ShareRepositoryError>;
|
||||
|
||||
/// Find all shares (admin operation)
|
||||
async fn find_all(&self) -> Result<Vec<Share>, ShareRepositoryError>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod file_fs_write_repository;
|
||||
pub mod trash_fs_repository;
|
||||
pub mod file_fs_repository_trash;
|
||||
pub mod folder_fs_repository_trash;
|
||||
pub mod share_fs_repository;
|
||||
|
||||
// Repositorios PostgreSQL
|
||||
pub mod pg;
|
||||
@@ -19,4 +20,5 @@ 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};
|
||||
pub use pg::{UserPgRepository, SessionPgRepository};
|
||||
pub use share_fs_repository::ShareFsRepository;
|
||||
@@ -85,7 +85,7 @@ impl UserRepository for UserPgRepository {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role,
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active
|
||||
FROM auth.users
|
||||
@@ -98,9 +98,9 @@ impl UserRepository for UserPgRepository {
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
// Convert role string to UserRole enum
|
||||
let role_str: String = row.get("role");
|
||||
let role = match role_str.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ impl UserRepository for UserPgRepository {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role,
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active
|
||||
FROM auth.users
|
||||
@@ -137,9 +137,9 @@ impl UserRepository for UserPgRepository {
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
// Convert role string to UserRole enum
|
||||
let role_str: String = row.get("role");
|
||||
let role = match role_str.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ impl UserRepository for UserPgRepository {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role,
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active
|
||||
FROM auth.users
|
||||
@@ -176,9 +176,9 @@ impl UserRepository for UserPgRepository {
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
// Convert role string to UserRole enum
|
||||
let role_str: String = row.get("role");
|
||||
let role = match role_str.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
@@ -276,7 +276,7 @@ impl UserRepository for UserPgRepository {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role,
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active
|
||||
FROM auth.users
|
||||
@@ -293,9 +293,9 @@ impl UserRepository for UserPgRepository {
|
||||
let users = rows.into_iter()
|
||||
.map(|row| {
|
||||
// Convert role string to UserRole enum for each row
|
||||
let role_str: String = row.get("role");
|
||||
let role = match role_str.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs, io};
|
||||
|
||||
use crate::{
|
||||
application::ports::share_ports::ShareStoragePort,
|
||||
common::{config::AppConfig, errors::DomainError},
|
||||
domain::{
|
||||
entities::share::{Share, ShareItemType},
|
||||
},
|
||||
};
|
||||
|
||||
// Estructura para almacenar en el sistema de archivos
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ShareRecord {
|
||||
id: String,
|
||||
item_id: String,
|
||||
item_type: String,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
expires_at: Option<u64>,
|
||||
permissions_read: bool,
|
||||
permissions_write: bool,
|
||||
permissions_reshare: bool,
|
||||
created_at: u64,
|
||||
created_by: String,
|
||||
access_count: u64,
|
||||
}
|
||||
|
||||
pub struct ShareFsRepository {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl ShareFsRepository {
|
||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Obtiene la ruta del archivo JSON donde se almacenan los enlaces compartidos
|
||||
fn get_shares_path(&self) -> String {
|
||||
format!("{}/shares.json", self.config.storage_path.display())
|
||||
}
|
||||
|
||||
/// Lee todos los enlaces compartidos del archivo JSON
|
||||
async fn read_shares(&self) -> Result<Vec<ShareRecord>, io::Error> {
|
||||
let path = self.get_shares_path();
|
||||
let path = Path::new(&path);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).await?;
|
||||
let shares: Vec<ShareRecord> = serde_json::from_str(&content).unwrap_or_default();
|
||||
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
/// Guarda todos los enlaces compartidos en el archivo JSON
|
||||
async fn write_shares(&self, shares: &[ShareRecord]) -> Result<(), io::Error> {
|
||||
let path = self.get_shares_path();
|
||||
let json = serde_json::to_string_pretty(shares)?;
|
||||
|
||||
// Asegúrate de que el directorio existe
|
||||
let dir = Path::new(&path).parent().unwrap();
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(dir).await?
|
||||
}
|
||||
|
||||
fs::write(path, json).await
|
||||
}
|
||||
|
||||
/// Convierte un registro del sistema de archivos a una entidad de dominio
|
||||
fn to_entity(&self, record: &ShareRecord) -> Share {
|
||||
let item_type = ShareItemType::try_from(record.item_type.as_str())
|
||||
.unwrap_or(ShareItemType::File);
|
||||
|
||||
let permissions = crate::domain::entities::share::SharePermissions::new(
|
||||
record.permissions_read,
|
||||
record.permissions_write,
|
||||
record.permissions_reshare,
|
||||
);
|
||||
|
||||
Share {
|
||||
id: record.id.clone(),
|
||||
item_id: record.item_id.clone(),
|
||||
item_type,
|
||||
token: record.token.clone(),
|
||||
password_hash: record.password_hash.clone(),
|
||||
expires_at: record.expires_at,
|
||||
permissions,
|
||||
created_at: record.created_at,
|
||||
created_by: record.created_by.clone(),
|
||||
access_count: record.access_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte una entidad de dominio a un registro para el sistema de archivos
|
||||
fn to_record(&self, share: &Share) -> ShareRecord {
|
||||
ShareRecord {
|
||||
id: share.id.clone(),
|
||||
item_id: share.item_id.clone(),
|
||||
item_type: share.item_type.to_string(),
|
||||
token: share.token.clone(),
|
||||
password_hash: share.password_hash.clone(),
|
||||
expires_at: share.expires_at,
|
||||
permissions_read: share.permissions.read,
|
||||
permissions_write: share.permissions.write,
|
||||
permissions_reshare: share.permissions.reshare,
|
||||
created_at: share.created_at,
|
||||
created_by: share.created_by.clone(),
|
||||
access_count: share.access_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShareStoragePort for ShareFsRepository {
|
||||
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
// Verifica si el enlace ya existe
|
||||
let existing_index = shares.iter().position(|s| s.id == share.id);
|
||||
|
||||
let record = self.to_record(share);
|
||||
|
||||
if let Some(index) = existing_index {
|
||||
// Actualización
|
||||
shares[index] = record;
|
||||
} else {
|
||||
// Inserción
|
||||
shares.push(record);
|
||||
}
|
||||
|
||||
self.write_shares(&shares).await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
|
||||
let shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
let share = shares.iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| {
|
||||
DomainError::not_found("Share", format!("Share with ID {} not found", id))
|
||||
});
|
||||
|
||||
match share {
|
||||
Ok(record) => Ok(self.to_entity(record)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||
let shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
let share = shares.iter()
|
||||
.find(|s| s.token == token)
|
||||
.ok_or_else(|| {
|
||||
DomainError::not_found("Share", format!("Share with token {} not found", token))
|
||||
});
|
||||
|
||||
match share {
|
||||
Ok(record) => Ok(self.to_entity(record)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
|
||||
let shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
let type_str = item_type.to_string();
|
||||
let result: Vec<Share> = shares.iter()
|
||||
.filter(|s| s.item_id == item_id && s.item_type == type_str)
|
||||
.map(|record| self.to_entity(record))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
// Busca el índice del enlace a actualizar
|
||||
let index = shares.iter().position(|s| s.id == share.id)
|
||||
.ok_or_else(|| {
|
||||
DomainError::not_found("Share", format!("Share with ID {} not found for update", share.id))
|
||||
})?;
|
||||
|
||||
// Actualiza el registro
|
||||
shares[index] = self.to_record(share);
|
||||
|
||||
// Guarda los cambios
|
||||
self.write_shares(&shares).await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
|
||||
let mut shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
// Encuentra el índice del enlace a eliminar
|
||||
let initial_len = shares.len();
|
||||
shares.retain(|s| s.id != id);
|
||||
|
||||
// Si no se eliminó ningún enlace, significa que no existía
|
||||
if shares.len() == initial_len {
|
||||
return Err(DomainError::not_found("Share", format!("Share with ID {} not found for deletion", id)));
|
||||
}
|
||||
|
||||
// Guarda los cambios
|
||||
self.write_shares(&shares).await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec<Share>, usize), DomainError> {
|
||||
let shares = self.read_shares().await
|
||||
.map_err(|e| DomainError::internal_error("Share", e.to_string()))?;
|
||||
|
||||
// Filtra los enlaces del usuario
|
||||
let user_shares: Vec<ShareRecord> = shares.into_iter()
|
||||
.filter(|s| s.created_by == user_id)
|
||||
.collect();
|
||||
|
||||
// Calcula el total
|
||||
let total = user_shares.len();
|
||||
|
||||
// Aplica la paginación
|
||||
let paginated: Vec<Share> = user_shares.iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|record| self.to_entity(record))
|
||||
.collect();
|
||||
|
||||
Ok((paginated, total))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod batch_handler;
|
||||
pub mod auth_handler;
|
||||
pub mod trash_handler;
|
||||
pub mod search_handler;
|
||||
pub mod share_handler;
|
||||
|
||||
/// Tipo de resultado para controladores de API
|
||||
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
application::{
|
||||
dtos::share_dto::{CreateShareDto, UpdateShareDto},
|
||||
ports::share_ports::ShareUseCase
|
||||
},
|
||||
common::errors::{DomainError, ErrorKind},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetSharesQuery {
|
||||
pub page: Option<usize>,
|
||||
pub per_page: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VerifyPasswordRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Create a new shared link
|
||||
pub async fn create_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Json(dto): Json<CreateShareDto>,
|
||||
) -> impl IntoResponse {
|
||||
// For now, we'll use a default user ID until auth is implemented
|
||||
let user_id = "default-user";
|
||||
match share_use_case.create_shared_link(&user_id, dto).await {
|
||||
Ok(share) => (StatusCode::CREATED, Json(share)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get information about a specific shared link by ID
|
||||
pub async fn get_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case.get_shared_link(&id).await {
|
||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all shared links created by the current user
|
||||
pub async fn get_user_shares(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Query(query): Query<GetSharesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
// For now, we'll use a default user ID until auth is implemented
|
||||
let user_id = "default-user";
|
||||
let page = query.page.unwrap_or(1);
|
||||
let per_page = query.per_page.unwrap_or(20);
|
||||
|
||||
match share_use_case.get_user_shared_links(&user_id, page, per_page).await {
|
||||
Ok(shares) => (StatusCode::OK, Json(shares)).into_response(),
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a shared link's properties
|
||||
pub async fn update_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<UpdateShareDto>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case.update_shared_link(&id, dto).await {
|
||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AccessDenied => StatusCode::FORBIDDEN,
|
||||
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a shared link
|
||||
pub async fn delete_shared_link(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case.delete_shared_link(&id).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AccessDenied => StatusCode::FORBIDDEN,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Access a shared item via its token
|
||||
pub async fn access_shared_item(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(token): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Register the access
|
||||
let _ = share_use_case.register_shared_link_access(&token).await;
|
||||
|
||||
// Get the shared link
|
||||
match share_use_case.get_shared_link_by_token(&token).await {
|
||||
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AccessDenied => {
|
||||
if err.message.contains("expired") {
|
||||
StatusCode::GONE // HTTP 410 Gone for expired links
|
||||
} else if err.message.contains("password") {
|
||||
return (StatusCode::UNAUTHORIZED, Json(json!({
|
||||
"error": "Password required",
|
||||
"requiresPassword": true
|
||||
}))).into_response();
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
},
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify password for a password-protected shared item
|
||||
pub async fn verify_shared_item_password(
|
||||
State(share_use_case): State<Arc<dyn ShareUseCase>>,
|
||||
Path(token): Path<String>,
|
||||
Json(req): Json<VerifyPasswordRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case.verify_shared_link_password(&token, &req.password).await {
|
||||
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AccessDenied => {
|
||||
if err.message.contains("expired") {
|
||||
StatusCode::GONE
|
||||
} else if err.message.contains("password") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
},
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(json!({ "error": err.to_string() }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,12 @@ use crate::application::services::i18n_application_service::I18nApplicationServi
|
||||
use crate::application::services::batch_operations::BatchOperationService;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::share_ports::ShareUseCase;
|
||||
|
||||
use crate::interfaces::api::handlers::folder_handler::FolderHandler;
|
||||
use crate::interfaces::api::handlers::file_handler::FileHandler;
|
||||
use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
|
||||
// Eliminamos la importación de ShareHandler ya que ahora usamos directamente el servicio
|
||||
use crate::interfaces::api::handlers::batch_handler::{
|
||||
self, BatchHandlerState
|
||||
};
|
||||
@@ -40,6 +42,7 @@ pub fn create_api_routes(
|
||||
i18n_service: Option<Arc<I18nApplicationService>>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
search_service: Option<Arc<dyn SearchUseCase>>,
|
||||
share_service: Option<Arc<dyn ShareUseCase>>,
|
||||
) -> Router<crate::common::di::AppState> {
|
||||
// Create a simplified AppState for the trash view
|
||||
// Setup required components for repository construction
|
||||
@@ -72,7 +75,7 @@ pub fn create_api_routes(
|
||||
path_service.clone(),
|
||||
));
|
||||
|
||||
let app_state = crate::common::di::AppState {
|
||||
let mut app_state = crate::common::di::AppState {
|
||||
core: crate::common::di::CoreServices {
|
||||
path_service: path_service.clone(),
|
||||
cache_manager: Arc::new(crate::infrastructure::services::cache_manager::StorageCacheManager::default()),
|
||||
@@ -102,10 +105,12 @@ pub fn create_api_routes(
|
||||
),
|
||||
trash_service: trash_service.clone(), // Include the trash service here too for consistency
|
||||
search_service: search_service.clone(), // Include the search service
|
||||
share_service: share_service.clone(), // Include the share service
|
||||
},
|
||||
db_pool: None,
|
||||
auth_service: None,
|
||||
trash_service: trash_service.clone(), // This is the important part - include the trash service
|
||||
share_service: share_service.clone() // Include the share service for routes
|
||||
};
|
||||
// Inicializar el servicio de operaciones por lotes
|
||||
let batch_service = Arc::new(BatchOperationService::default(
|
||||
@@ -284,12 +289,49 @@ pub fn create_api_routes(
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Implementaciones directas de handlers para compartir, sin depender de ShareHandler
|
||||
|
||||
// Create routes for shared resources if the service is available
|
||||
let share_router = if let Some(share_service) = share_service.clone() {
|
||||
use crate::interfaces::api::handlers::share_handler;
|
||||
|
||||
Router::new()
|
||||
.route("/", post(share_handler::create_shared_link))
|
||||
.route("/", get(share_handler::get_user_shares))
|
||||
.route("/{id}", get(share_handler::get_shared_link))
|
||||
.route("/{id}", put(share_handler::update_shared_link))
|
||||
.route("/{id}", delete(share_handler::delete_shared_link))
|
||||
.with_state(share_service.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Public route for accessing shared links
|
||||
let public_share_router = if let Some(share_service) = share_service.clone() {
|
||||
use crate::interfaces::api::handlers::share_handler;
|
||||
|
||||
Router::new()
|
||||
.route("/{token}", get(share_handler::access_shared_item))
|
||||
.route("/{token}/verify", post(share_handler::verify_shared_item_password))
|
||||
.with_state(share_service.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Create a router without the i18n routes
|
||||
let mut router = Router::new()
|
||||
.nest("/folders", folders_router)
|
||||
.nest("/files", files_router)
|
||||
.nest("/batch", batch_router)
|
||||
.nest("/search", search_router);
|
||||
.nest("/search", search_router)
|
||||
.nest("/shares", share_router)
|
||||
.nest("/s", public_share_router)
|
||||
;
|
||||
|
||||
// Store the share service in app_state for future use
|
||||
if let Some(share_service) = share_service.clone() {
|
||||
app_state.share_service = Some(share_service);
|
||||
}
|
||||
|
||||
// Re-enable trash routes to make the trash view work
|
||||
if let Some(_trash_service_ref) = trash_service.clone() {
|
||||
|
||||
+24
-1
@@ -45,10 +45,12 @@ use application::services::folder_service::FolderService;
|
||||
use application::services::file_service::FileService;
|
||||
use application::services::i18n_application_service::I18nApplicationService;
|
||||
use application::services::storage_mediator::FileSystemStorageMediator;
|
||||
use application::services::share_service::ShareService;
|
||||
use domain::services::path_service::PathService;
|
||||
use infrastructure::repositories::folder_fs_repository::FolderFsRepository;
|
||||
use infrastructure::repositories::file_fs_repository::FileFsRepository;
|
||||
use infrastructure::repositories::parallel_file_processor::ParallelFileProcessor;
|
||||
use infrastructure::repositories::share_fs_repository::ShareFsRepository;
|
||||
use infrastructure::services::file_system_i18n_service::FileSystemI18nService;
|
||||
use infrastructure::services::id_mapping_service::IdMappingService;
|
||||
use infrastructure::services::id_mapping_optimizer::IdMappingOptimizer;
|
||||
@@ -591,6 +593,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::info!("Search service initialized with caching (TTL: 300s, max entries: 1000)");
|
||||
Some(search_service)
|
||||
};
|
||||
|
||||
// Initialize share repository and service if enabled
|
||||
let share_service: Option<Arc<dyn application::ports::share_ports::ShareUseCase>> = if config.features.enable_file_sharing {
|
||||
let share_repository = Arc::new(ShareFsRepository::new(
|
||||
Arc::new(config.clone())
|
||||
));
|
||||
|
||||
let share_service = Arc::new(ShareService::new(
|
||||
Arc::new(config.clone()),
|
||||
share_repository,
|
||||
file_repository.clone(),
|
||||
folder_repository.clone()
|
||||
));
|
||||
|
||||
tracing::info!("File sharing service initialized successfully");
|
||||
Some(share_service)
|
||||
} else {
|
||||
tracing::info!("File sharing service is disabled in configuration");
|
||||
None
|
||||
};
|
||||
|
||||
let application_services = common::di::ApplicationServices {
|
||||
folder_service: folder_service.clone(),
|
||||
@@ -602,6 +624,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
i18n_service: i18n_service.clone(),
|
||||
trash_service: trash_service.clone(),
|
||||
search_service: search_service.clone(),
|
||||
share_service: share_service.clone(),
|
||||
};
|
||||
|
||||
// Create the AppState without Arc first
|
||||
@@ -626,7 +649,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app_state = Arc::new(app_state);
|
||||
|
||||
// Build application router
|
||||
let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service);
|
||||
let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service, share_service);
|
||||
let web_routes = create_web_routes();
|
||||
|
||||
// Build the app router
|
||||
|
||||
+609
-33
@@ -888,61 +888,620 @@ body {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* Compartir diálogo */
|
||||
.share-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 50px;
|
||||
width: 250px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
padding: 15px;
|
||||
/* Dialogs (Rename, Move, Share) */
|
||||
.rename-dialog, .share-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
.rename-dialog-content, .share-dialog-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.share-dialog-content {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.rename-dialog-header, .share-dialog-header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: #2d3748;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e0e6ed;
|
||||
}
|
||||
|
||||
.share-user {
|
||||
.rename-dialog input, .share-dialog input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rename-dialog-buttons, .share-dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Share dialog specific styles */
|
||||
.shared-item-info {
|
||||
padding: 10px 0;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.share-options h3, #existing-shares-section h3, #new-share-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-options {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.permission-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.existing-share-item {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #dfe4ea;
|
||||
.share-url {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 5px;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.share-protected {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.share-expiration {
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.share-link-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
#notification-message {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Shared Resources Page Styles */
|
||||
/* Header styles for shared page */
|
||||
header {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
padding: 8px 15px;
|
||||
background-color: #f0f3f7;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background-color: #e6e9ed;
|
||||
}
|
||||
|
||||
/* Shared page container */
|
||||
.shared-page-container {
|
||||
max-width: 1280px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.shared-header {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.shared-header h2 {
|
||||
font-size: 24px;
|
||||
color: #2d3748;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.shared-header p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Filters and search */
|
||||
.shared-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 8px 15px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
padding: 8px 15px;
|
||||
background-color: #ff5e3a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background-color: #e64a29;
|
||||
}
|
||||
|
||||
/* Shared items list */
|
||||
.shared-list-container {
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shared-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.shared-list thead th {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #e0e6ed;
|
||||
}
|
||||
|
||||
.shared-list tbody td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.shared-item-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shared-item-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
.action-btn:hover {
|
||||
background-color: #f0f8ff;
|
||||
border-color: #90cdf4;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 20px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
color: #2d3748;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #718096;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dialog styles */
|
||||
.dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
font-size: 18px;
|
||||
color: #2d3748;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-dialog-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Share dialog specific styles */
|
||||
.share-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.add-user {
|
||||
.share-link-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-link-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.share-link-container input {
|
||||
flex-grow: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.share-link-container button {
|
||||
padding: 0 15px;
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.share-link-container button:hover {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
.share-settings {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-setting {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.share-setting label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.permissions-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.permissions-options label {
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin-top: 10px;
|
||||
background-color: #f0f3f7;
|
||||
border-radius: 50px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.password-setting, .expiration-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.password-setting input[type="checkbox"], .expiration-setting input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.password-setting input[type="password"], .expiration-setting input[type="date"] {
|
||||
flex-grow: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.password-setting button {
|
||||
padding: 8px 12px;
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.password-setting button:hover {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.form-group input, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: #ff5e3a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background-color: #e64a29;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: #f56565;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
background-color: #e53e3e;
|
||||
}
|
||||
|
||||
/* Notification banner */
|
||||
.notification-banner {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 400px;
|
||||
z-index: 2000;
|
||||
transform: translateY(-100px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.notification-banner.active {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-banner.success {
|
||||
border-left: 4px solid #48bb78;
|
||||
}
|
||||
|
||||
.notification-banner.error {
|
||||
border-left: 4px solid #f56565;
|
||||
}
|
||||
|
||||
.close-notification-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #a0aec0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -968,6 +1527,23 @@ body {
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive styles for shared page */
|
||||
.shared-filters {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.shared-list thead th:nth-child(4),
|
||||
.shared-list thead th:nth-child(5),
|
||||
.shared-list tbody td:nth-child(4),
|
||||
.shared-list tbody td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Folder selection for move dialog */
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/languageSelector.js"></script>
|
||||
<script src="/js/fileRenderer.js"></script>
|
||||
<script src="/js/fileSharing.js"></script>
|
||||
<script src="/js/contextMenus.js"></script>
|
||||
<script src="/js/fileOperations.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
@@ -49,7 +50,7 @@
|
||||
<i class="fas fa-folder"></i>
|
||||
<span data-i18n="nav.files">Files</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" id="nav-shared">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
<span data-i18n="nav.shared">Shared</span>
|
||||
</div>
|
||||
|
||||
+22
-2
@@ -15,6 +15,10 @@ const app = {
|
||||
isTrashView: false, // Whether we're in trash view
|
||||
currentSection: 'files', // Current section: 'files' or 'trash'
|
||||
isSearchMode: false, // Whether we're in search mode
|
||||
// File sharing related properties
|
||||
shareDialogItem: null, // Item being shared in share dialog
|
||||
shareDialogItemType: null, // Type of item being shared ('file' or 'folder')
|
||||
notificationShareUrl: null // URL for notification dialog
|
||||
};
|
||||
|
||||
// DOM elements
|
||||
@@ -29,8 +33,17 @@ function initApp() {
|
||||
// Cache DOM elements
|
||||
cacheElements();
|
||||
|
||||
// Create menus and dialogs
|
||||
ui.initializeContextMenus();
|
||||
// Initialize file sharing module first
|
||||
if (window.fileSharing && window.fileSharing.init) {
|
||||
window.fileSharing.init();
|
||||
} else {
|
||||
console.warn('fileSharing module not fully initialized');
|
||||
}
|
||||
|
||||
// Then create menus and dialogs after modules have initialized
|
||||
setTimeout(() => {
|
||||
ui.initializeContextMenus();
|
||||
}, 100);
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
@@ -152,6 +165,13 @@ function setupEventListeners() {
|
||||
// Add active class to clicked item
|
||||
item.classList.add('active');
|
||||
|
||||
// Check if this is the shared item
|
||||
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.shared') {
|
||||
// Navigate to the shared page
|
||||
window.location.href = '/shared.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the trash item
|
||||
if (item === elements.trashBtn) {
|
||||
// Show trash view
|
||||
|
||||
+81
-42
@@ -17,46 +17,78 @@ 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');
|
||||
let loginPanel, registerPanel, adminSetupPanel;
|
||||
let loginForm, registerForm, adminSetupForm;
|
||||
let loginError, registerError, registerSuccess, adminSetupError;
|
||||
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const registerForm = document.getElementById('register-form');
|
||||
const adminSetupForm = document.getElementById('admin-setup-form');
|
||||
// Initialize DOM elements only if we're on the login page
|
||||
function initLoginElements() {
|
||||
// Check if we're on the login page
|
||||
if (!document.getElementById('login-form')) {
|
||||
console.log('Not on login page, skipping element initialization');
|
||||
return false;
|
||||
}
|
||||
|
||||
loginPanel = document.getElementById('login-panel');
|
||||
registerPanel = document.getElementById('register-panel');
|
||||
adminSetupPanel = document.getElementById('admin-setup-panel');
|
||||
|
||||
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');
|
||||
loginForm = document.getElementById('login-form');
|
||||
registerForm = document.getElementById('register-form');
|
||||
adminSetupForm = document.getElementById('admin-setup-form');
|
||||
|
||||
// Panel toggles
|
||||
document.getElementById('show-register').addEventListener('click', () => {
|
||||
loginPanel.style.display = 'none';
|
||||
registerPanel.style.display = 'block';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
});
|
||||
loginError = document.getElementById('login-error');
|
||||
registerError = document.getElementById('register-error');
|
||||
registerSuccess = document.getElementById('register-success');
|
||||
adminSetupError = document.getElementById('admin-setup-error');
|
||||
|
||||
document.getElementById('show-login').addEventListener('click', () => {
|
||||
loginPanel.style.display = 'block';
|
||||
registerPanel.style.display = 'none';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
});
|
||||
// Panel toggles
|
||||
document.getElementById('show-register').addEventListener('click', () => {
|
||||
loginPanel.style.display = 'none';
|
||||
registerPanel.style.display = 'block';
|
||||
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('show-login').addEventListener('click', () => {
|
||||
loginPanel.style.display = 'block';
|
||||
registerPanel.style.display = 'none';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('back-to-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';
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Initialize login elements if on login page
|
||||
const isLoginPage = initLoginElements();
|
||||
|
||||
// Check if we already have a valid token
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let authInitialized = false;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if we're on the login page
|
||||
if (!document.getElementById('login-form')) {
|
||||
console.log('Not on login page, skipping auth check');
|
||||
return;
|
||||
}
|
||||
|
||||
if (authInitialized) {
|
||||
console.log('Auth already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
authInitialized = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
||||
if (tokenExpiry && new Date(tokenExpiry) > new Date()) {
|
||||
@@ -87,17 +119,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
} 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;
|
||||
if (isLoginPage && loginForm) {
|
||||
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);
|
||||
@@ -161,9 +195,11 @@ loginForm.addEventListener('submit', async (e) => {
|
||||
loginError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register form submission
|
||||
registerForm.addEventListener('submit', async (e) => {
|
||||
if (isLoginPage && registerForm) {
|
||||
registerForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous messages
|
||||
@@ -202,9 +238,11 @@ registerForm.addEventListener('submit', async (e) => {
|
||||
registerError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin setup form submission
|
||||
adminSetupForm.addEventListener('submit', async (e) => {
|
||||
if (isLoginPage && adminSetupForm) {
|
||||
adminSetupForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous errors
|
||||
@@ -235,6 +273,7 @@ adminSetupForm.addEventListener('submit', async (e) => {
|
||||
adminSetupError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ const contextMenus = {
|
||||
}
|
||||
window.ui.closeContextMenu();
|
||||
});
|
||||
|
||||
document.getElementById('share-folder-option').addEventListener('click', () => {
|
||||
if (window.app.contextMenuTargetFolder) {
|
||||
this.showShareDialog(window.app.contextMenuTargetFolder, 'folder');
|
||||
}
|
||||
window.ui.closeContextMenu();
|
||||
});
|
||||
|
||||
document.getElementById('delete-folder-option').addEventListener('click', async () => {
|
||||
if (window.app.contextMenuTargetFolder) {
|
||||
@@ -42,6 +49,13 @@ const contextMenus = {
|
||||
window.ui.closeFileContextMenu();
|
||||
});
|
||||
|
||||
document.getElementById('share-file-option').addEventListener('click', () => {
|
||||
if (window.app.contextMenuTargetFile) {
|
||||
this.showShareDialog(window.app.contextMenuTargetFile, 'file');
|
||||
}
|
||||
window.ui.closeFileContextMenu();
|
||||
});
|
||||
|
||||
document.getElementById('delete-file-option').addEventListener('click', async () => {
|
||||
if (window.app.contextMenuTargetFile) {
|
||||
await window.fileOps.deleteFile(
|
||||
@@ -249,6 +263,226 @@ const contextMenus = {
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Show share dialog for files or folders
|
||||
* @param {Object} item - File or folder object
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
*/
|
||||
showShareDialog(item, itemType) {
|
||||
// Update dialog title based on item type
|
||||
const dialogHeader = document.getElementById('share-dialog').querySelector('.share-dialog-header');
|
||||
const itemName = document.getElementById('shared-item-name');
|
||||
|
||||
// Update dialog content
|
||||
dialogHeader.textContent = itemType === 'file' ?
|
||||
(window.i18n ? window.i18n.t('dialogs.share_file') : 'Compartir archivo') :
|
||||
(window.i18n ? window.i18n.t('dialogs.share_folder') : 'Compartir carpeta');
|
||||
|
||||
itemName.textContent = item.name;
|
||||
|
||||
// Reset form
|
||||
document.getElementById('share-password').value = '';
|
||||
document.getElementById('share-expiration').value = '';
|
||||
document.getElementById('share-permission-read').checked = true;
|
||||
document.getElementById('share-permission-write').checked = false;
|
||||
document.getElementById('share-permission-reshare').checked = false;
|
||||
|
||||
// Store the current item and type for use when creating the share
|
||||
window.app.shareDialogItem = item;
|
||||
window.app.shareDialogItemType = itemType;
|
||||
|
||||
// Check if item already has shares
|
||||
const existingShares = window.fileSharing.getSharedLinksForItem(item.id, itemType);
|
||||
const existingSharesContainer = document.getElementById('existing-shares-container');
|
||||
|
||||
// Clear existing shares container
|
||||
existingSharesContainer.innerHTML = '';
|
||||
|
||||
if (existingShares.length > 0) {
|
||||
document.getElementById('existing-shares-section').style.display = 'block';
|
||||
|
||||
// Create elements for each existing share
|
||||
existingShares.forEach(share => {
|
||||
const shareEl = document.createElement('div');
|
||||
shareEl.className = 'existing-share-item';
|
||||
|
||||
const expiresText = share.expires_at ?
|
||||
`Vence: ${window.fileSharing.formatExpirationDate(share.expires_at)}` :
|
||||
'Sin vencimiento';
|
||||
|
||||
shareEl.innerHTML = `
|
||||
<div class="share-url">${share.url}</div>
|
||||
<div class="share-info">
|
||||
${share.password_protected ? '<span class="share-protected"><i class="fas fa-lock"></i> Con contraseña</span>' : ''}
|
||||
<span class="share-expiration">${expiresText}</span>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button class="btn btn-small copy-link-btn" data-share-url="${share.url}">
|
||||
<i class="fas fa-copy"></i> Copiar
|
||||
</button>
|
||||
<button class="btn btn-small btn-danger delete-link-btn" data-share-id="${share.id}">
|
||||
<i class="fas fa-trash"></i> Eliminar
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
existingSharesContainer.appendChild(shareEl);
|
||||
});
|
||||
|
||||
// Add event listeners for copy and delete buttons
|
||||
document.querySelectorAll('.copy-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('data-share-url');
|
||||
window.fileSharing.copyLinkToClipboard(url);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const shareId = btn.getAttribute('data-share-id');
|
||||
|
||||
if (confirm('¿Estás seguro de que quieres eliminar este enlace compartido?')) {
|
||||
window.fileSharing.removeSharedLink(shareId);
|
||||
btn.closest('.existing-share-item').remove();
|
||||
|
||||
// Check if we still have shares
|
||||
if (existingSharesContainer.children.length === 0) {
|
||||
document.getElementById('existing-shares-section').style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
document.getElementById('existing-shares-section').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show dialog
|
||||
document.getElementById('share-dialog').style.display = 'flex';
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a shared link with the configured options
|
||||
*/
|
||||
createSharedLink() {
|
||||
if (!window.app.shareDialogItem || !window.app.shareDialogItemType) {
|
||||
window.ui.showNotification('Error', 'No se pudo compartir el elemento');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get values from form
|
||||
const password = document.getElementById('share-password').value;
|
||||
const expirationDate = document.getElementById('share-expiration').value;
|
||||
const permissionRead = document.getElementById('share-permission-read').checked;
|
||||
const permissionWrite = document.getElementById('share-permission-write').checked;
|
||||
const permissionReshare = document.getElementById('share-permission-reshare').checked;
|
||||
|
||||
// Prepare options
|
||||
const options = {
|
||||
password: password || null,
|
||||
expirationDate: expirationDate || null,
|
||||
permissions: {
|
||||
read: permissionRead,
|
||||
write: permissionWrite,
|
||||
reshare: permissionReshare
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const item = window.app.shareDialogItem;
|
||||
const itemType = window.app.shareDialogItemType;
|
||||
|
||||
// Create share
|
||||
const shareInfo = window.fileSharing.generateSharedLink(
|
||||
item.id,
|
||||
itemType,
|
||||
options
|
||||
);
|
||||
|
||||
// Update UI with new share
|
||||
const shareUrl = document.getElementById('generated-share-url');
|
||||
shareUrl.value = shareInfo.url;
|
||||
document.getElementById('new-share-section').style.display = 'block';
|
||||
|
||||
// Focus and select for easy copying
|
||||
shareUrl.focus();
|
||||
shareUrl.select();
|
||||
|
||||
// Show success message
|
||||
window.ui.showNotification('Enlace creado', 'Enlace compartido creado correctamente');
|
||||
|
||||
// Reload existing shares
|
||||
this.showShareDialog(item, itemType);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating shared link:', error);
|
||||
window.ui.showNotification('Error', 'No se pudo crear el enlace compartido');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show email notification dialog
|
||||
* @param {string} shareUrl - URL to share
|
||||
*/
|
||||
showEmailNotificationDialog(shareUrl) {
|
||||
// Update dialog content
|
||||
document.getElementById('notification-share-url').textContent = shareUrl;
|
||||
document.getElementById('notification-email').value = '';
|
||||
document.getElementById('notification-message').value = '';
|
||||
|
||||
// Store the URL for later use
|
||||
window.app.notificationShareUrl = shareUrl;
|
||||
|
||||
// Show dialog
|
||||
document.getElementById('notification-dialog').style.display = 'flex';
|
||||
},
|
||||
|
||||
/**
|
||||
* Send share notification email
|
||||
*/
|
||||
sendShareNotification() {
|
||||
const email = document.getElementById('notification-email').value.trim();
|
||||
const message = document.getElementById('notification-message').value.trim();
|
||||
const shareUrl = window.app.notificationShareUrl;
|
||||
|
||||
if (!email || !shareUrl) {
|
||||
window.ui.showNotification('Error', 'Por favor, ingresa un correo electrónico válido');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
window.ui.showNotification('Error', 'Por favor, ingresa un correo electrónico válido');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.fileSharing.sendShareNotification(shareUrl, email, message);
|
||||
document.getElementById('notification-dialog').style.display = 'none';
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
window.ui.showNotification('Error', 'No se pudo enviar la notificación');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close share dialog
|
||||
*/
|
||||
closeShareDialog() {
|
||||
document.getElementById('share-dialog').style.display = 'none';
|
||||
window.app.shareDialogItem = null;
|
||||
window.app.shareDialogItemType = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Close notification dialog
|
||||
*/
|
||||
closeNotificationDialog() {
|
||||
document.getElementById('notification-dialog').style.display = 'none';
|
||||
window.app.notificationShareUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* OxiCloud - File Sharing Module
|
||||
* This file handles file sharing functionality (shared links, permissions, etc.)
|
||||
*/
|
||||
|
||||
// File Sharing Module
|
||||
const fileSharing = {
|
||||
/**
|
||||
* Generate a shared link for a file or folder
|
||||
* @param {string} itemId - ID of the file or folder
|
||||
* @param {string} itemType - Type ('file' or 'folder')
|
||||
* @param {Object} options - Sharing options (password, expiration, etc.)
|
||||
* @returns {Object} - Shared link information
|
||||
*/
|
||||
generateSharedLink(itemId, itemType, options = {}) {
|
||||
try {
|
||||
// In a real implementation, this would be a call to the backend
|
||||
// But for now, we'll simulate it with a mock response
|
||||
|
||||
// Default options
|
||||
const defaultOptions = {
|
||||
password: null,
|
||||
expirationDate: null,
|
||||
permissions: {
|
||||
read: true,
|
||||
write: false,
|
||||
reshare: false
|
||||
}
|
||||
};
|
||||
|
||||
// Merge options
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
// Generate a mock link (would normally come from server)
|
||||
const linkId = Math.random().toString(36).substring(2, 15);
|
||||
const shareToken = Math.random().toString(36).substring(2, 20);
|
||||
const baseUrl = window.location.origin;
|
||||
const sharedUrl = `${baseUrl}/s/${shareToken}`;
|
||||
|
||||
// Create expiration date if set
|
||||
let expiresAt = null;
|
||||
if (finalOptions.expirationDate) {
|
||||
expiresAt = new Date(finalOptions.expirationDate);
|
||||
}
|
||||
|
||||
// Create a mock response that matches what we'd expect from the server
|
||||
const response = {
|
||||
id: linkId,
|
||||
type: itemType,
|
||||
itemId: itemId,
|
||||
url: sharedUrl,
|
||||
token: shareToken,
|
||||
password_protected: !!finalOptions.password,
|
||||
expires_at: expiresAt ? expiresAt.toISOString() : null,
|
||||
permissions: finalOptions.permissions,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: {
|
||||
id: "current-user-id", // Would be the actual user ID
|
||||
username: "current-user" // Would be the actual username
|
||||
},
|
||||
access_count: 0,
|
||||
// Add some UI friendly properties for shared.js compatibility
|
||||
name: options.name || "Shared Item",
|
||||
dateShared: new Date().toISOString(),
|
||||
expiration: expiresAt ? expiresAt.toISOString() : null,
|
||||
password: finalOptions.password
|
||||
};
|
||||
|
||||
// In a real implementation, we would store this link in localStorage for now
|
||||
// until backend implementation is ready
|
||||
this.saveSharedLink(response);
|
||||
|
||||
// Return the "response" as if it came from the server
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error generating shared link:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a shared link to localStorage (temporary storage until backend is ready)
|
||||
* @param {Object} linkData - Shared link data
|
||||
*/
|
||||
saveSharedLink(linkData) {
|
||||
try {
|
||||
// Get existing shared links
|
||||
const existingLinks = JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]');
|
||||
|
||||
// Add new link
|
||||
existingLinks.push(linkData);
|
||||
|
||||
// Save back to localStorage
|
||||
localStorage.setItem('oxicloud_shared_links', JSON.stringify(existingLinks));
|
||||
} catch (error) {
|
||||
console.error('Error saving shared link to local storage:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a shared link
|
||||
* @param {string} linkId - ID of the shared link to remove
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
removeSharedLink(linkId) {
|
||||
try {
|
||||
// Get existing shared links
|
||||
const existingLinks = JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]');
|
||||
|
||||
// Filter out the link to remove
|
||||
const updatedLinks = existingLinks.filter(link => link.id !== linkId);
|
||||
|
||||
// Save back to localStorage
|
||||
localStorage.setItem('oxicloud_shared_links', JSON.stringify(updatedLinks));
|
||||
|
||||
// Removed network delay simulation
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing shared link:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a shared link's properties
|
||||
* @param {string} linkId - ID of the shared link to update
|
||||
* @param {Object} updateData - Properties to update
|
||||
* @returns {Promise<Object>} - Updated shared link
|
||||
*/
|
||||
updateSharedLink(linkId, updateData) {
|
||||
try {
|
||||
// Get existing shared links
|
||||
const existingLinks = JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]');
|
||||
|
||||
// Find the link to update
|
||||
const linkIndex = existingLinks.findIndex(link => link.id === linkId);
|
||||
if (linkIndex === -1) {
|
||||
throw new Error('Shared link not found');
|
||||
}
|
||||
|
||||
// Update link data
|
||||
existingLinks[linkIndex] = {
|
||||
...existingLinks[linkIndex],
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save back to localStorage
|
||||
localStorage.setItem('oxicloud_shared_links', JSON.stringify(existingLinks));
|
||||
|
||||
// Removed network delay simulation
|
||||
|
||||
return existingLinks[linkIndex];
|
||||
} catch (error) {
|
||||
console.error('Error updating shared link:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all shared links for the current user
|
||||
* @returns {Promise<Array>} - Array of shared links
|
||||
*/
|
||||
getSharedLinks() {
|
||||
try {
|
||||
// Get shared links from localStorage
|
||||
const links = JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]');
|
||||
|
||||
// Removed network delay simulation
|
||||
|
||||
return links;
|
||||
} catch (error) {
|
||||
console.error('Error getting shared links:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get shared links for a specific item
|
||||
* @param {string} itemId - ID of the file or folder
|
||||
* @param {string} itemType - Type ('file' or 'folder')
|
||||
* @returns {Promise<Array>} - Array of shared links for the item
|
||||
*/
|
||||
getSharedLinksForItem(itemId, itemType) {
|
||||
try {
|
||||
// Get all shared links
|
||||
const allLinks = this.getSharedLinks();
|
||||
|
||||
// Filter by item ID and type
|
||||
return allLinks.filter(link => link.itemId === itemId && link.type === itemType);
|
||||
} catch (error) {
|
||||
console.error('Error getting shared links for item:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an item has any shared links
|
||||
* @param {string} itemId - ID of the file or folder
|
||||
* @param {string} itemType - Type ('file' or 'folder')
|
||||
* @returns {Promise<boolean>} - True if the item has shared links
|
||||
*/
|
||||
hasSharedLinks(itemId, itemType) {
|
||||
const links = this.getSharedLinksForItem(itemId, itemType);
|
||||
return links.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy a shared link to clipboard
|
||||
* @param {string} url - URL to copy
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
copyLinkToClipboard(url) {
|
||||
try {
|
||||
navigator.clipboard.writeText(url);
|
||||
window.ui.showNotification('Enlace copiado', 'Enlace copiado al portapapeles');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
window.ui.showNotification('Error', 'No se pudo copiar el enlace');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format expiration date for display
|
||||
* @param {string} dateString - ISO date string
|
||||
* @returns {string} - Formatted date string
|
||||
*/
|
||||
formatExpirationDate(dateString) {
|
||||
if (!dateString) return 'Sin vencimiento';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a notification about a shared resource
|
||||
* @param {string} shareUrl - The URL of the shared resource
|
||||
* @param {string} recipientEmail - Email of the recipient
|
||||
* @param {string} message - Optional message to include
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
sendShareNotification(shareUrl, recipientEmail, message = '') {
|
||||
try {
|
||||
// In a real implementation, this would call the backend
|
||||
// For now, we'll just simulate a successful notification
|
||||
console.log(`Share notification for ${shareUrl} sent to ${recipientEmail}`);
|
||||
console.log(`Message: ${message || 'No message included'}`);
|
||||
|
||||
// Simulate network delay
|
||||
//await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
window.ui.showNotification('Notificación enviada', `Se envió notificación a ${recipientEmail}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending share notification:', error);
|
||||
window.ui.showNotification('Error', 'No se pudo enviar la notificación');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize file sharing event listeners and UI elements
|
||||
*/
|
||||
init() {
|
||||
// This will be called by the app.js initialization
|
||||
console.log('File sharing module initialized');
|
||||
|
||||
// Add "Shared" view event listeners
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.shared') {
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = '/shared.html';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all shared links
|
||||
* @returns {Array} Array of shared links
|
||||
*/
|
||||
function getSharedLinks() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]');
|
||||
} catch (error) {
|
||||
console.error('Error getting shared links:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a shared link
|
||||
* @param {string} linkId - ID of the link to update
|
||||
* @param {Object} updateData - Data to update
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function updateSharedLink(linkId, updateData) {
|
||||
try {
|
||||
const links = getSharedLinks();
|
||||
const index = links.findIndex(link => link.id === linkId);
|
||||
if (index === -1) return false;
|
||||
|
||||
links[index] = {...links[index], ...updateData};
|
||||
localStorage.setItem('oxicloud_shared_links', JSON.stringify(links));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating shared link:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a shared link
|
||||
* @param {string} linkId - ID of the link to remove
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function removeSharedLink(linkId) {
|
||||
try {
|
||||
const links = getSharedLinks();
|
||||
const filteredLinks = links.filter(link => link.id !== linkId);
|
||||
localStorage.setItem('oxicloud_shared_links', JSON.stringify(filteredLinks));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing shared link:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about a shared link
|
||||
* @param {string} linkId - ID of the link
|
||||
* @param {string} email - Recipient email
|
||||
* @param {string} message - Optional message
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
function sendShareNotification(linkId, email, message = '') {
|
||||
return new Promise((resolve) => {
|
||||
console.log(`Notification for link ${linkId} sent to ${email}`);
|
||||
console.log(`Message: ${message || 'No message'}`);
|
||||
setTimeout(() => resolve(true), 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text using i18n if available
|
||||
* @param {string} key - Translation key
|
||||
* @param {string} defaultText - Default text if translation not found
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
function translate(key, defaultText) {
|
||||
if (window.i18n && window.i18n.t) {
|
||||
return window.i18n.t(key, defaultText);
|
||||
}
|
||||
return defaultText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n module
|
||||
*/
|
||||
function initializeI18n() {
|
||||
if (window.i18n && window.i18n.init) {
|
||||
window.i18n.init();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose functions globally
|
||||
window.getSharedLinks = getSharedLinks;
|
||||
window.updateSharedLink = updateSharedLink;
|
||||
window.removeSharedLink = removeSharedLink;
|
||||
window.sendShareNotification = sendShareNotification;
|
||||
window.translate = translate;
|
||||
window.initializeI18n = initializeI18n;
|
||||
|
||||
// Expose file sharing module globally
|
||||
window.fileSharing = fileSharing;
|
||||
+29
-18
@@ -34,12 +34,7 @@ async function loadTranslations(locale) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/i18n/locales/${locale}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load translations for ${locale}`);
|
||||
}
|
||||
|
||||
// Fetch the actual JSON file directly if the API doesn't provide a full translations object
|
||||
// Load directly from local JSON file
|
||||
const localeData = await fetch(`/locales/${locale}.json`);
|
||||
if (!localeData.ok) {
|
||||
throw new Error(`Failed to load locale file for ${locale}`);
|
||||
@@ -50,17 +45,6 @@ async function loadTranslations(locale) {
|
||||
} catch (error) {
|
||||
console.error('Error loading translations:', error);
|
||||
|
||||
// Try to load from file directly as fallback
|
||||
try {
|
||||
const fallbackResponse = await fetch(`/locales/${locale}.json`);
|
||||
if (fallbackResponse.ok) {
|
||||
translations[locale] = await fallbackResponse.json();
|
||||
return translations[locale];
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('Error loading fallback translations:', fallbackError);
|
||||
}
|
||||
|
||||
// Return empty object as last resort
|
||||
translations[locale] = {};
|
||||
return translations[locale];
|
||||
@@ -74,6 +58,13 @@ async function loadTranslations(locale) {
|
||||
* @returns {string|null} - The translation value or null if not found
|
||||
*/
|
||||
function getNestedValue(obj, path) {
|
||||
// Try direct key match first
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return (typeof value === 'string') ? value : null;
|
||||
}
|
||||
|
||||
// Try standard dot notation for nested values
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
@@ -81,11 +72,21 @@ function getNestedValue(obj, path) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key];
|
||||
} else {
|
||||
// Key not found in standard dotted path
|
||||
// Try a last attempt with underscore format if this is a prefix_suffix format key
|
||||
if (path.includes('_') && !path.includes('.')) {
|
||||
const [prefix, ...parts] = path.split('_');
|
||||
const suffix = parts.join('_');
|
||||
|
||||
if (obj[prefix] && typeof obj[prefix] === 'object' && suffix in obj[prefix]) {
|
||||
return obj[prefix][suffix];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : null;
|
||||
return (typeof current === 'string') ? current : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,16 @@ function t(key, params = {}) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// Special handling for shared_ and share_ prefixed keys
|
||||
if (key.startsWith('shared_') || key.startsWith('share_')) {
|
||||
const unprefixedKey = key.replace(/^(shared|share)_/, '');
|
||||
const prefixObj = key.startsWith('shared_') ? localeData.shared : localeData.share;
|
||||
|
||||
if (prefixObj && typeof prefixObj === 'object' && unprefixedKey in prefixObj) {
|
||||
return interpolate(prefixObj[unprefixedKey], params);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the translation value
|
||||
const value = getNestedValue(localeData, key);
|
||||
if (!value) {
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* OxiCloud Shared Resources Page
|
||||
* Manages the display and interaction with shared files and folders
|
||||
*/
|
||||
|
||||
// Authentication check function
|
||||
function checkAuthentication() {
|
||||
// Names of variables from 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.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Display username in notification if available
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
if (userData.username) {
|
||||
console.log(`Authenticated as ${userData.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize i18n
|
||||
await initializeI18n();
|
||||
|
||||
// Wait a moment for translations to fully load
|
||||
setTimeout(() => {
|
||||
// Manually translate all elements with data-i18n attribute
|
||||
if (window.i18n && window.i18n.translatePage) {
|
||||
window.i18n.translatePage();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Elements
|
||||
const sharedItemsList = document.getElementById('shared-items-list');
|
||||
const emptySharedState = document.getElementById('empty-shared-state');
|
||||
const filterType = document.getElementById('filter-type');
|
||||
const sortBy = document.getElementById('sort-by');
|
||||
const sharedSearch = document.getElementById('shared-search');
|
||||
const sharedSearchBtn = document.getElementById('shared-search-btn');
|
||||
const goToFilesBtn = document.getElementById('go-to-files');
|
||||
|
||||
// Share dialog elements
|
||||
const shareDialog = document.getElementById('share-dialog');
|
||||
const shareDialogCloseBtn = shareDialog.querySelector('.close-dialog-btn');
|
||||
const shareDialogIcon = document.getElementById('share-dialog-icon');
|
||||
const shareDialogName = document.getElementById('share-dialog-name');
|
||||
const shareLinkUrl = document.getElementById('share-link-url');
|
||||
const copyLinkBtn = document.getElementById('copy-link-btn');
|
||||
const enablePassword = document.getElementById('enable-password');
|
||||
const sharePassword = document.getElementById('share-password');
|
||||
const generatePasswordBtn = document.getElementById('generate-password');
|
||||
const enableExpiration = document.getElementById('enable-expiration');
|
||||
const shareExpiration = document.getElementById('share-expiration');
|
||||
const permissionRead = document.getElementById('permission-read');
|
||||
const permissionWrite = document.getElementById('permission-write');
|
||||
const permissionReshare = document.getElementById('permission-reshare');
|
||||
const updateShareBtn = document.getElementById('update-share-btn');
|
||||
const removeShareBtn = document.getElementById('remove-share-btn');
|
||||
|
||||
// Notification dialog elements
|
||||
const notificationDialog = document.getElementById('share-notification-dialog');
|
||||
const notificationCloseBtn = notificationDialog.querySelector('.close-dialog-btn');
|
||||
const notifyDialogIcon = document.getElementById('notify-dialog-icon');
|
||||
const notifyDialogName = document.getElementById('notify-dialog-name');
|
||||
const notificationEmail = document.getElementById('notification-email');
|
||||
const notificationMessage = document.getElementById('notification-message');
|
||||
const sendNotificationBtn = document.getElementById('send-notification-btn');
|
||||
|
||||
// Notification banner
|
||||
const notificationBanner = document.getElementById('notification-banner');
|
||||
const notificationBannerMessage = document.getElementById('notification-message');
|
||||
const closeNotificationBtn = document.getElementById('close-notification');
|
||||
|
||||
// Current state
|
||||
let currentSharedItem = null;
|
||||
let allSharedItems = [];
|
||||
let filteredItems = [];
|
||||
|
||||
// Initialize the page
|
||||
loadSharedItems();
|
||||
|
||||
// Event listeners
|
||||
filterType.addEventListener('change', filterAndSortItems);
|
||||
sortBy.addEventListener('change', filterAndSortItems);
|
||||
sharedSearchBtn.addEventListener('click', filterAndSortItems);
|
||||
sharedSearch.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter') filterAndSortItems();
|
||||
});
|
||||
goToFilesBtn.addEventListener('click', () => window.location.href = '/');
|
||||
|
||||
// Check authentication before loading
|
||||
checkAuthentication();
|
||||
|
||||
// Share dialog event listeners
|
||||
shareDialogCloseBtn.addEventListener('click', () => closeShareDialog());
|
||||
copyLinkBtn.addEventListener('click', copyShareLink);
|
||||
enablePassword.addEventListener('change', () => {
|
||||
sharePassword.disabled = !enablePassword.checked;
|
||||
if (enablePassword.checked) sharePassword.focus();
|
||||
});
|
||||
generatePasswordBtn.addEventListener('click', generatePassword);
|
||||
enableExpiration.addEventListener('change', () => {
|
||||
shareExpiration.disabled = !enableExpiration.checked;
|
||||
if (enableExpiration.checked) shareExpiration.focus();
|
||||
});
|
||||
updateShareBtn.addEventListener('click', updateSharedItem);
|
||||
removeShareBtn.addEventListener('click', removeSharedItem);
|
||||
|
||||
// Notification dialog event listeners
|
||||
notificationCloseBtn.addEventListener('click', () => closeNotificationDialog());
|
||||
sendNotificationBtn.addEventListener('click', sendNotification);
|
||||
|
||||
// Notification banner event listeners
|
||||
closeNotificationBtn.addEventListener('click', () => {
|
||||
notificationBanner.classList.remove('active');
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads all shared items and displays them
|
||||
*/
|
||||
function loadSharedItems() {
|
||||
// Get shared links from storage
|
||||
allSharedItems = getSharedLinks();
|
||||
|
||||
// Display items
|
||||
filterAndSortItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and sorts the shared items based on current filters
|
||||
*/
|
||||
function filterAndSortItems() {
|
||||
const type = filterType.value;
|
||||
const sort = sortBy.value;
|
||||
const searchTerm = sharedSearch.value.toLowerCase();
|
||||
|
||||
// Filter items
|
||||
filteredItems = allSharedItems.filter(item => {
|
||||
// Filter by type
|
||||
if (type !== 'all' && item.type !== type) return false;
|
||||
|
||||
// Filter by search term
|
||||
const nameMatch = item.name.toLowerCase().includes(searchTerm);
|
||||
return nameMatch;
|
||||
});
|
||||
|
||||
// Sort items
|
||||
filteredItems.sort((a, b) => {
|
||||
if (sort === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else if (sort === 'date') {
|
||||
return new Date(b.dateShared) - new Date(a.dateShared);
|
||||
} else if (sort === 'expiration') {
|
||||
// Handle null expiration dates (items without expiration come last)
|
||||
if (!a.expiration && !b.expiration) return 0;
|
||||
if (!a.expiration) return 1;
|
||||
if (!b.expiration) return -1;
|
||||
return new Date(a.expiration) - new Date(b.expiration);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Display filtered and sorted items
|
||||
displaySharedItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the filtered and sorted shared items
|
||||
*/
|
||||
function displaySharedItems() {
|
||||
// Clear the list
|
||||
sharedItemsList.innerHTML = '';
|
||||
|
||||
// Show empty state if no items
|
||||
if (filteredItems.length === 0) {
|
||||
emptySharedState.style.display = 'flex';
|
||||
document.querySelector('.shared-list-container').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide empty state and show table
|
||||
emptySharedState.style.display = 'none';
|
||||
document.querySelector('.shared-list-container').style.display = 'block';
|
||||
|
||||
// Add items to the list
|
||||
filteredItems.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Icon and name
|
||||
const nameCell = document.createElement('td');
|
||||
nameCell.className = 'shared-item-name';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'item-icon';
|
||||
icon.textContent = item.type === 'file' ? '📄' : '📁';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = item.name;
|
||||
nameCell.appendChild(icon);
|
||||
nameCell.appendChild(name);
|
||||
|
||||
// Type
|
||||
const typeCell = document.createElement('td');
|
||||
typeCell.textContent = item.type === 'file' ? translate('shared_typeFile', 'File') : translate('shared_typeFolder', 'Folder');
|
||||
|
||||
// Date shared
|
||||
const dateCell = document.createElement('td');
|
||||
dateCell.textContent = formatDate(item.dateShared);
|
||||
|
||||
// Expiration
|
||||
const expirationCell = document.createElement('td');
|
||||
expirationCell.textContent = item.expiration ? formatDate(item.expiration) : translate('shared_noExpiration', 'No expiration');
|
||||
|
||||
// Permissions
|
||||
const permissionsCell = document.createElement('td');
|
||||
const permissions = [];
|
||||
if (item.permissions.read) permissions.push(translate('share_permissionRead', 'Read'));
|
||||
if (item.permissions.write) permissions.push(translate('share_permissionWrite', 'Write'));
|
||||
if (item.permissions.reshare) permissions.push(translate('share_permissionReshare', 'Reshare'));
|
||||
permissionsCell.textContent = permissions.join(', ');
|
||||
|
||||
// Password
|
||||
const passwordCell = document.createElement('td');
|
||||
passwordCell.textContent = item.password ? translate('shared_hasPassword', 'Yes') : translate('shared_noPassword', 'No');
|
||||
|
||||
// Actions
|
||||
const actionsCell = document.createElement('td');
|
||||
actionsCell.className = 'shared-item-actions';
|
||||
|
||||
// Edit button
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'action-btn edit-btn';
|
||||
editBtn.innerHTML = '<span class="action-icon">✏️</span>';
|
||||
editBtn.title = translate('shared_editShare', 'Edit Share');
|
||||
editBtn.addEventListener('click', () => openShareDialog(item));
|
||||
|
||||
// Notify button
|
||||
const notifyBtn = document.createElement('button');
|
||||
notifyBtn.className = 'action-btn notify-btn';
|
||||
notifyBtn.innerHTML = '<span class="action-icon">📧</span>';
|
||||
notifyBtn.title = translate('shared_notifyShare', 'Notify Someone');
|
||||
notifyBtn.addEventListener('click', () => openNotificationDialog(item));
|
||||
|
||||
// Copy link button
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'action-btn copy-btn';
|
||||
copyBtn.innerHTML = '<span class="action-icon">📋</span>';
|
||||
copyBtn.title = translate('shared_copyLink', 'Copy Link');
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(item.url)
|
||||
.then(() => showNotification(translate('shared_linkCopied', 'Link copied to clipboard!')))
|
||||
.catch(err => showNotification(translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
});
|
||||
|
||||
// Remove button
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'action-btn remove-btn';
|
||||
removeBtn.innerHTML = '<span class="action-icon">🗑️</span>';
|
||||
removeBtn.title = translate('shared_removeShare', 'Remove Share');
|
||||
removeBtn.addEventListener('click', () => {
|
||||
currentSharedItem = item;
|
||||
removeSharedItem();
|
||||
});
|
||||
|
||||
actionsCell.appendChild(editBtn);
|
||||
actionsCell.appendChild(notifyBtn);
|
||||
actionsCell.appendChild(copyBtn);
|
||||
actionsCell.appendChild(removeBtn);
|
||||
|
||||
// Add cells to row
|
||||
row.appendChild(nameCell);
|
||||
row.appendChild(typeCell);
|
||||
row.appendChild(dateCell);
|
||||
row.appendChild(expirationCell);
|
||||
row.appendChild(permissionsCell);
|
||||
row.appendChild(passwordCell);
|
||||
row.appendChild(actionsCell);
|
||||
|
||||
// Add row to table
|
||||
sharedItemsList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the share dialog for the given item
|
||||
*/
|
||||
function openShareDialog(item) {
|
||||
currentSharedItem = item;
|
||||
|
||||
// Set dialog content
|
||||
shareDialogIcon.textContent = item.type === 'file' ? '📄' : '📁';
|
||||
shareDialogName.textContent = item.name;
|
||||
shareLinkUrl.value = item.url;
|
||||
|
||||
// Set permissions
|
||||
permissionRead.checked = item.permissions.read;
|
||||
permissionWrite.checked = item.permissions.write;
|
||||
permissionReshare.checked = item.permissions.reshare;
|
||||
|
||||
// Set password
|
||||
enablePassword.checked = !!item.password;
|
||||
sharePassword.disabled = !enablePassword.checked;
|
||||
sharePassword.value = item.password || '';
|
||||
|
||||
// Set expiration
|
||||
enableExpiration.checked = !!item.expiration;
|
||||
shareExpiration.disabled = !enableExpiration.checked;
|
||||
shareExpiration.value = item.expiration ? new Date(item.expiration).toISOString().split('T')[0] : '';
|
||||
|
||||
// Show dialog
|
||||
shareDialog.classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the share dialog
|
||||
*/
|
||||
function closeShareDialog() {
|
||||
shareDialog.classList.remove('active');
|
||||
currentSharedItem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the notification dialog for the given item
|
||||
*/
|
||||
function openNotificationDialog(item) {
|
||||
currentSharedItem = item;
|
||||
|
||||
// Set dialog content
|
||||
notifyDialogIcon.textContent = item.type === 'file' ? '📄' : '📁';
|
||||
notifyDialogName.textContent = item.name;
|
||||
notificationEmail.value = '';
|
||||
notificationMessage.value = '';
|
||||
|
||||
// Show dialog
|
||||
notificationDialog.classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the notification dialog
|
||||
*/
|
||||
function closeNotificationDialog() {
|
||||
notificationDialog.classList.remove('active');
|
||||
currentSharedItem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the current share link to clipboard
|
||||
*/
|
||||
function copyShareLink() {
|
||||
navigator.clipboard.writeText(shareLinkUrl.value)
|
||||
.then(() => showNotification(translate('shared_linkCopied', 'Link copied to clipboard!')))
|
||||
.catch(err => showNotification(translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random password for the share
|
||||
*/
|
||||
function generatePassword() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
let password = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
sharePassword.value = password;
|
||||
enablePassword.checked = true;
|
||||
sharePassword.disabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current shared item with new settings
|
||||
*/
|
||||
function updateSharedItem() {
|
||||
if (!currentSharedItem) return;
|
||||
|
||||
// Get updated settings
|
||||
const permissions = {
|
||||
read: permissionRead.checked,
|
||||
write: permissionWrite.checked,
|
||||
reshare: permissionReshare.checked
|
||||
};
|
||||
|
||||
const password = enablePassword.checked ? sharePassword.value : null;
|
||||
const expiration = enableExpiration.checked ? shareExpiration.value : null;
|
||||
|
||||
// Update the shared link
|
||||
updateSharedLink(currentSharedItem.id, {
|
||||
permissions,
|
||||
password,
|
||||
expiration: expiration ? new Date(expiration).toISOString() : null
|
||||
});
|
||||
|
||||
// Reload items and close dialog
|
||||
loadSharedItems();
|
||||
closeShareDialog();
|
||||
|
||||
// Show notification
|
||||
showNotification(translate('shared_itemUpdated', 'Share settings updated successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current shared item
|
||||
*/
|
||||
function removeSharedItem() {
|
||||
if (!currentSharedItem) return;
|
||||
|
||||
// Remove the shared link
|
||||
removeSharedLink(currentSharedItem.id);
|
||||
|
||||
// Reload items and close dialog if open
|
||||
loadSharedItems();
|
||||
closeShareDialog();
|
||||
|
||||
// Show notification
|
||||
showNotification(translate('shared_itemRemoved', 'Share removed successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification email for the current shared item
|
||||
*/
|
||||
function sendNotification() {
|
||||
if (!currentSharedItem) return;
|
||||
|
||||
const email = notificationEmail.value.trim();
|
||||
const message = notificationMessage.value.trim();
|
||||
|
||||
// Validate email
|
||||
if (!email || !validateEmail(email)) {
|
||||
showNotification(translate('shared_invalidEmail', 'Please enter a valid email address'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send notification
|
||||
sendShareNotification(currentSharedItem.id, email, message)
|
||||
.then(() => {
|
||||
closeNotificationDialog();
|
||||
showNotification(translate('shared_notificationSent', 'Notification sent successfully'));
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification(translate('shared_notificationFailed', 'Failed to send notification'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification banner with the given message
|
||||
*/
|
||||
function showNotification(message, type = 'success') {
|
||||
notificationBannerMessage.textContent = message;
|
||||
notificationBanner.className = 'notification-banner active ' + type;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
notificationBanner.classList.remove('active');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address
|
||||
*/
|
||||
function validateEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to a user-friendly format
|
||||
*/
|
||||
function formatDate(dateString) {
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
}
|
||||
});
|
||||
+142
-1
@@ -21,6 +21,9 @@ const ui = {
|
||||
<div class="context-menu-item" id="move-folder-option">
|
||||
<i class="fas fa-exchange-alt"></i> <span data-i18n="actions.move">Mover a...</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="share-folder-option">
|
||||
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Compartir</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="delete-folder-option">
|
||||
<i class="fas fa-trash"></i> <span data-i18n="actions.delete">Eliminar</span>
|
||||
</div>
|
||||
@@ -34,6 +37,9 @@ const ui = {
|
||||
fileMenu.className = 'context-menu';
|
||||
fileMenu.id = 'file-context-menu';
|
||||
fileMenu.innerHTML = `
|
||||
<div class="context-menu-item" id="share-file-option">
|
||||
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Compartir</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="move-file-option">
|
||||
<i class="fas fa-exchange-alt"></i> <span data-i18n="actions.move">Mover a...</span>
|
||||
</div>
|
||||
@@ -85,9 +91,144 @@ const ui = {
|
||||
`;
|
||||
document.body.appendChild(moveDialog);
|
||||
}
|
||||
|
||||
// Share dialog
|
||||
if (!document.getElementById('share-dialog')) {
|
||||
const shareDialog = document.createElement('div');
|
||||
shareDialog.className = 'share-dialog';
|
||||
shareDialog.id = 'share-dialog';
|
||||
shareDialog.innerHTML = `
|
||||
<div class="share-dialog-content">
|
||||
<div class="share-dialog-header" data-i18n="dialogs.share_file">Compartir archivo</div>
|
||||
<div class="shared-item-info">
|
||||
<strong>Elemento:</strong> <span id="shared-item-name"></span>
|
||||
</div>
|
||||
|
||||
<div id="existing-shares-section" style="display:none; margin: 15px 0;">
|
||||
<h3 data-i18n="dialogs.existing_shares">Enlaces compartidos existentes</h3>
|
||||
<div id="existing-shares-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="share-options">
|
||||
<h3 data-i18n="dialogs.share_options">Opciones de compartición</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="share-password" data-i18n="dialogs.password">Contraseña (opcional):</label>
|
||||
<input type="password" id="share-password" placeholder="Proteger con contraseña">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="share-expiration" data-i18n="dialogs.expiration">Fecha de vencimiento (opcional):</label>
|
||||
<input type="date" id="share-expiration">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="dialogs.permissions">Permisos:</label>
|
||||
<div class="permission-options">
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-read" checked>
|
||||
<label for="share-permission-read" data-i18n="permissions.read">Lectura</label>
|
||||
</div>
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-write">
|
||||
<label for="share-permission-write" data-i18n="permissions.write">Escritura</label>
|
||||
</div>
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-reshare">
|
||||
<label for="share-permission-reshare" data-i18n="permissions.reshare">Permitir compartir</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="new-share-section" style="display:none; margin: 15px 0;">
|
||||
<h3 data-i18n="dialogs.generated_link">Enlace generado</h3>
|
||||
<div class="form-group">
|
||||
<input type="text" id="generated-share-url" readonly>
|
||||
<div class="share-link-actions">
|
||||
<button class="btn btn-small" id="copy-share-btn">
|
||||
<i class="fas fa-copy"></i> <span data-i18n="actions.copy">Copiar</span>
|
||||
</button>
|
||||
<button class="btn btn-small" id="notify-share-btn">
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="actions.notify">Notificar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-dialog-buttons">
|
||||
<button class="btn" id="share-cancel-btn" data-i18n="actions.cancel">Cancelar</button>
|
||||
<button class="btn btn-primary" id="share-confirm-btn" data-i18n="actions.share">Compartir</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(shareDialog);
|
||||
|
||||
// Add event listeners for share dialog
|
||||
document.getElementById('share-cancel-btn').addEventListener('click', () => {
|
||||
contextMenus.closeShareDialog();
|
||||
});
|
||||
|
||||
document.getElementById('share-confirm-btn').addEventListener('click', () => {
|
||||
contextMenus.createSharedLink();
|
||||
});
|
||||
|
||||
document.getElementById('copy-share-btn').addEventListener('click', async () => {
|
||||
const shareUrl = document.getElementById('generated-share-url').value;
|
||||
await fileSharing.copyLinkToClipboard(shareUrl);
|
||||
});
|
||||
|
||||
document.getElementById('notify-share-btn').addEventListener('click', () => {
|
||||
const shareUrl = document.getElementById('generated-share-url').value;
|
||||
contextMenus.showEmailNotificationDialog(shareUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// Notification dialog
|
||||
if (!document.getElementById('notification-dialog')) {
|
||||
const notificationDialog = document.createElement('div');
|
||||
notificationDialog.className = 'share-dialog';
|
||||
notificationDialog.id = 'notification-dialog';
|
||||
notificationDialog.innerHTML = `
|
||||
<div class="share-dialog-content">
|
||||
<div class="share-dialog-header" data-i18n="dialogs.notify">Notificar enlace compartido</div>
|
||||
|
||||
<p><strong>URL:</strong> <span id="notification-share-url"></span></p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notification-email" data-i18n="dialogs.recipient">Destinatario:</label>
|
||||
<input type="email" id="notification-email" placeholder="Correo electrónico">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notification-message" data-i18n="dialogs.message">Mensaje (opcional):</label>
|
||||
<textarea id="notification-message" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="share-dialog-buttons">
|
||||
<button class="btn" id="notification-cancel-btn" data-i18n="actions.cancel">Cancelar</button>
|
||||
<button class="btn btn-primary" id="notification-send-btn" data-i18n="actions.send">Enviar</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(notificationDialog);
|
||||
|
||||
// Add event listeners for notification dialog
|
||||
document.getElementById('notification-cancel-btn').addEventListener('click', () => {
|
||||
contextMenus.closeNotificationDialog();
|
||||
});
|
||||
|
||||
document.getElementById('notification-send-btn').addEventListener('click', () => {
|
||||
contextMenus.sendShareNotification();
|
||||
});
|
||||
}
|
||||
|
||||
// Assign events to menu items
|
||||
contextMenus.assignMenuEvents();
|
||||
if (window.contextMenus) {
|
||||
window.contextMenus.assignMenuEvents();
|
||||
} else {
|
||||
console.warn('contextMenus module not loaded');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+140
-2
@@ -19,7 +19,130 @@
|
||||
"move_to": "Move to",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
"confirm": "Confirm",
|
||||
"share": "Share",
|
||||
"copy": "Copy",
|
||||
"notify": "Notify",
|
||||
"send": "Send"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Share Link",
|
||||
"linkLabel": "Share Link:",
|
||||
"copyLink": "Copy",
|
||||
"permissions": "Permissions:",
|
||||
"permissionRead": "Read",
|
||||
"permissionWrite": "Write",
|
||||
"permissionReshare": "Reshare",
|
||||
"password": "Password Protection:",
|
||||
"generatePassword": "Generate",
|
||||
"expiration": "Expiration Date:",
|
||||
"update": "Update Share",
|
||||
"remove": "Remove Share",
|
||||
"notifyTitle": "Send Notification",
|
||||
"notifyEmailLabel": "Email Address:",
|
||||
"notifyMessageLabel": "Message (optional):",
|
||||
"notifySend": "Send Notification",
|
||||
"shareWithOthers": "Share with others",
|
||||
"sharePublicly": "Share publicly",
|
||||
"shareSettings": "Sharing settings",
|
||||
"shareCopied": "Link copied to clipboard",
|
||||
"shareCreated": "Share link created successfully",
|
||||
"shareUpdated": "Share settings updated successfully",
|
||||
"shareRemoved": "Share removed successfully"
|
||||
},
|
||||
"share_dialogTitle": "Share Link",
|
||||
"share_linkLabel": "Share Link:",
|
||||
"share_copyLink": "Copy",
|
||||
"share_permissions": "Permissions:",
|
||||
"share_permissionRead": "Read",
|
||||
"share_permissionWrite": "Write",
|
||||
"share_permissionReshare": "Reshare",
|
||||
"share_password": "Password Protection:",
|
||||
"share_generatePassword": "Generate",
|
||||
"share_expiration": "Expiration Date:",
|
||||
"share_update": "Update Share",
|
||||
"share_remove": "Remove Share",
|
||||
"share_notifyTitle": "Send Notification",
|
||||
"share_notifyEmailLabel": "Email Address:",
|
||||
"share_notifyMessageLabel": "Message (optional):",
|
||||
"share_notifySend": "Send Notification",
|
||||
"shared": {
|
||||
"backToFiles": "Back to Files",
|
||||
"pageTitle": "Shared Resources",
|
||||
"pageDescription": "Manage your shared files and folders",
|
||||
"filterType": "Type:",
|
||||
"filterAll": "All",
|
||||
"filterFiles": "Files",
|
||||
"filterFolders": "Folders",
|
||||
"sortBy": "Sort by:",
|
||||
"sortByName": "Name",
|
||||
"sortByDate": "Date shared",
|
||||
"sortByExpiration": "Expiration",
|
||||
"search": "Search",
|
||||
"colName": "Name",
|
||||
"colType": "Type",
|
||||
"colDateShared": "Date Shared",
|
||||
"colExpiration": "Expiration",
|
||||
"colPermissions": "Permissions",
|
||||
"colPassword": "Password",
|
||||
"colActions": "Actions",
|
||||
"emptyStateTitle": "No shared resources yet",
|
||||
"emptyStateDesc": "When you share files or folders, they will appear here",
|
||||
"goToFiles": "Go to Files",
|
||||
"typeFile": "File",
|
||||
"typeFolder": "Folder",
|
||||
"noExpiration": "No expiration",
|
||||
"hasPassword": "Yes",
|
||||
"noPassword": "No",
|
||||
"editShare": "Edit Share",
|
||||
"notifyShare": "Notify Someone",
|
||||
"copyLink": "Copy Link",
|
||||
"removeShare": "Remove Share",
|
||||
"linkCopied": "Link copied to clipboard!",
|
||||
"linkCopyFailed": "Failed to copy link",
|
||||
"itemUpdated": "Share settings updated successfully",
|
||||
"itemRemoved": "Share removed successfully",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"notificationSent": "Notification sent successfully",
|
||||
"notificationFailed": "Failed to send notification",
|
||||
"shared_backToFiles": "Back to Files",
|
||||
"shared_pageTitle": "Shared Resources",
|
||||
"shared_pageDescription": "Manage your shared files and folders",
|
||||
"shared_filterType": "Type:",
|
||||
"shared_filterAll": "All",
|
||||
"shared_filterFiles": "Files",
|
||||
"shared_filterFolders": "Folders",
|
||||
"shared_sortBy": "Sort by:",
|
||||
"shared_sortByName": "Name",
|
||||
"shared_sortByDate": "Date shared",
|
||||
"shared_sortByExpiration": "Expiration",
|
||||
"shared_search": "Search",
|
||||
"shared_colName": "Name",
|
||||
"shared_colType": "Type",
|
||||
"shared_colDateShared": "Date Shared",
|
||||
"shared_colExpiration": "Expiration",
|
||||
"shared_colPermissions": "Permissions",
|
||||
"shared_colPassword": "Password",
|
||||
"shared_colActions": "Actions",
|
||||
"shared_emptyStateTitle": "No shared resources yet",
|
||||
"shared_emptyStateDesc": "When you share files or folders, they will appear here",
|
||||
"shared_goToFiles": "Go to Files",
|
||||
"shared_typeFile": "File",
|
||||
"shared_typeFolder": "Folder",
|
||||
"shared_noExpiration": "No expiration",
|
||||
"shared_hasPassword": "Yes",
|
||||
"shared_noPassword": "No",
|
||||
"shared_editShare": "Edit Share",
|
||||
"shared_notifyShare": "Notify Someone",
|
||||
"shared_copyLink": "Copy Link",
|
||||
"shared_removeShare": "Remove Share",
|
||||
"shared_linkCopied": "Link copied to clipboard!",
|
||||
"shared_linkCopyFailed": "Failed to copy link",
|
||||
"shared_itemUpdated": "Share settings updated successfully",
|
||||
"shared_itemRemoved": "Share removed successfully",
|
||||
"shared_invalidEmail": "Please enter a valid email address",
|
||||
"shared_notificationSent": "Notification sent successfully",
|
||||
"shared_notificationFailed": "Failed to send notification"
|
||||
},
|
||||
"files": {
|
||||
"name": "Name",
|
||||
@@ -47,12 +170,27 @@
|
||||
"root": "Root",
|
||||
"delete_confirmation": "Are you sure you want to delete",
|
||||
"and_contents": "and all its contents",
|
||||
"no_undo": "This action cannot be undone"
|
||||
"no_undo": "This action cannot be undone",
|
||||
"share_file": "Share File",
|
||||
"existing_shares": "Existing Shares",
|
||||
"share_options": "Share Options",
|
||||
"password": "Password",
|
||||
"expiration": "Expiration",
|
||||
"permissions": "Permissions",
|
||||
"generated_link": "Generated Link",
|
||||
"notify": "Send Notification",
|
||||
"recipient": "Recipient",
|
||||
"message": "Message"
|
||||
},
|
||||
"dropzone": {
|
||||
"drag_files": "Drag files here or click to select",
|
||||
"drop_files": "Drop files to upload"
|
||||
},
|
||||
"permissions": {
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"reshare": "Reshare"
|
||||
},
|
||||
"errors": {
|
||||
"file_not_found": "File not found",
|
||||
"folder_not_found": "Folder not found",
|
||||
|
||||
+140
-2
@@ -10,6 +10,125 @@
|
||||
"favorites": "Favoritos",
|
||||
"trash": "Papelera"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Compartir Enlace",
|
||||
"linkLabel": "Enlace compartido:",
|
||||
"copyLink": "Copiar",
|
||||
"permissions": "Permisos:",
|
||||
"permissionRead": "Lectura",
|
||||
"permissionWrite": "Escritura",
|
||||
"permissionReshare": "Recompartir",
|
||||
"password": "Protección con contraseña:",
|
||||
"generatePassword": "Generar",
|
||||
"expiration": "Fecha de caducidad:",
|
||||
"update": "Actualizar compartido",
|
||||
"remove": "Eliminar compartido",
|
||||
"notifyTitle": "Enviar notificación",
|
||||
"notifyEmailLabel": "Dirección de correo:",
|
||||
"notifyMessageLabel": "Mensaje (opcional):",
|
||||
"notifySend": "Enviar notificación",
|
||||
"shareWithOthers": "Compartir con otros",
|
||||
"sharePublicly": "Compartir públicamente",
|
||||
"shareSettings": "Configuración de compartido",
|
||||
"shareCopied": "Enlace copiado al portapapeles",
|
||||
"shareCreated": "Enlace compartido creado correctamente",
|
||||
"shareUpdated": "Configuración de compartido actualizada",
|
||||
"shareRemoved": "Compartido eliminado correctamente"
|
||||
},
|
||||
"share_dialogTitle": "Compartir Enlace",
|
||||
"share_linkLabel": "Enlace compartido:",
|
||||
"share_copyLink": "Copiar",
|
||||
"share_permissions": "Permisos:",
|
||||
"share_permissionRead": "Lectura",
|
||||
"share_permissionWrite": "Escritura",
|
||||
"share_permissionReshare": "Recompartir",
|
||||
"share_password": "Protección con contraseña:",
|
||||
"share_generatePassword": "Generar",
|
||||
"share_expiration": "Fecha de caducidad:",
|
||||
"share_update": "Actualizar compartido",
|
||||
"share_remove": "Eliminar compartido",
|
||||
"share_notifyTitle": "Enviar notificación",
|
||||
"share_notifyEmailLabel": "Dirección de correo:",
|
||||
"share_notifyMessageLabel": "Mensaje (opcional):",
|
||||
"share_notifySend": "Enviar notificación",
|
||||
"shared": {
|
||||
"backToFiles": "Volver a Archivos",
|
||||
"pageTitle": "Recursos Compartidos",
|
||||
"pageDescription": "Administra tus archivos y carpetas compartidos",
|
||||
"filterType": "Tipo:",
|
||||
"filterAll": "Todos",
|
||||
"filterFiles": "Archivos",
|
||||
"filterFolders": "Carpetas",
|
||||
"sortBy": "Ordenar por:",
|
||||
"sortByName": "Nombre",
|
||||
"sortByDate": "Fecha compartido",
|
||||
"sortByExpiration": "Caducidad",
|
||||
"search": "Buscar",
|
||||
"colName": "Nombre",
|
||||
"colType": "Tipo",
|
||||
"colDateShared": "Fecha compartido",
|
||||
"colExpiration": "Caducidad",
|
||||
"colPermissions": "Permisos",
|
||||
"colPassword": "Contraseña",
|
||||
"colActions": "Acciones",
|
||||
"emptyStateTitle": "Aún no hay recursos compartidos",
|
||||
"emptyStateDesc": "Cuando compartas archivos o carpetas, aparecerán aquí",
|
||||
"goToFiles": "Ir a Archivos",
|
||||
"typeFile": "Archivo",
|
||||
"typeFolder": "Carpeta",
|
||||
"noExpiration": "Sin caducidad",
|
||||
"hasPassword": "Sí",
|
||||
"noPassword": "No",
|
||||
"editShare": "Editar compartido",
|
||||
"notifyShare": "Notificar a alguien",
|
||||
"copyLink": "Copiar enlace",
|
||||
"removeShare": "Eliminar compartido",
|
||||
"linkCopied": "¡Enlace copiado al portapapeles!",
|
||||
"linkCopyFailed": "Error al copiar el enlace",
|
||||
"itemUpdated": "Configuración de compartido actualizada",
|
||||
"itemRemoved": "Compartido eliminado correctamente",
|
||||
"invalidEmail": "Por favor, introduce una dirección de correo válida",
|
||||
"notificationSent": "Notificación enviada correctamente",
|
||||
"notificationFailed": "Error al enviar la notificación"
|
||||
},
|
||||
"shared_backToFiles": "Volver a Archivos",
|
||||
"shared_pageTitle": "Recursos Compartidos",
|
||||
"shared_pageDescription": "Administra tus archivos y carpetas compartidos",
|
||||
"shared_filterType": "Tipo:",
|
||||
"shared_filterAll": "Todos",
|
||||
"shared_filterFiles": "Archivos",
|
||||
"shared_filterFolders": "Carpetas",
|
||||
"shared_sortBy": "Ordenar por:",
|
||||
"shared_sortByName": "Nombre",
|
||||
"shared_sortByDate": "Fecha compartido",
|
||||
"shared_sortByExpiration": "Caducidad",
|
||||
"shared_search": "Buscar",
|
||||
"shared_colName": "Nombre",
|
||||
"shared_colType": "Tipo",
|
||||
"shared_colDateShared": "Fecha compartido",
|
||||
"shared_colExpiration": "Caducidad",
|
||||
"shared_colPermissions": "Permisos",
|
||||
"shared_colPassword": "Contraseña",
|
||||
"shared_colActions": "Acciones",
|
||||
"shared_emptyStateTitle": "Aún no hay recursos compartidos",
|
||||
"shared_emptyStateDesc": "Cuando compartas archivos o carpetas, aparecerán aquí",
|
||||
"shared_goToFiles": "Ir a Archivos",
|
||||
"shared_typeFile": "Archivo",
|
||||
"shared_typeFolder": "Carpeta",
|
||||
"shared_noExpiration": "Sin caducidad",
|
||||
"shared_hasPassword": "Sí",
|
||||
"shared_noPassword": "No",
|
||||
"shared_editShare": "Editar compartido",
|
||||
"shared_notifyShare": "Notificar a alguien",
|
||||
"shared_copyLink": "Copiar enlace",
|
||||
"shared_removeShare": "Eliminar compartido",
|
||||
"shared_linkCopied": "¡Enlace copiado al portapapeles!",
|
||||
"shared_linkCopyFailed": "Error al copiar el enlace",
|
||||
"shared_itemUpdated": "Configuración de compartido actualizada",
|
||||
"shared_itemRemoved": "Compartido eliminado correctamente",
|
||||
"shared_invalidEmail": "Por favor, introduce una dirección de correo válida",
|
||||
"shared_notificationSent": "Notificación enviada correctamente",
|
||||
"shared_notificationFailed": "Error al enviar la notificación",
|
||||
"actions": {
|
||||
"search": "Buscar archivos...",
|
||||
"new_folder": "Nueva carpeta",
|
||||
@@ -19,7 +138,11 @@
|
||||
"move_to": "Mover a",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
"confirm": "Confirmar",
|
||||
"share": "Compartir",
|
||||
"copy": "Copiar",
|
||||
"notify": "Notificar",
|
||||
"send": "Enviar"
|
||||
},
|
||||
"files": {
|
||||
"name": "Nombre",
|
||||
@@ -47,12 +170,27 @@
|
||||
"root": "Raíz",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar",
|
||||
"and_contents": "y todo su contenido",
|
||||
"no_undo": "Esta acción no se puede deshacer"
|
||||
"no_undo": "Esta acción no se puede deshacer",
|
||||
"share_file": "Compartir Archivo",
|
||||
"existing_shares": "Compartidos Existentes",
|
||||
"share_options": "Opciones de Compartición",
|
||||
"password": "Contraseña",
|
||||
"expiration": "Caducidad",
|
||||
"permissions": "Permisos",
|
||||
"generated_link": "Enlace Generado",
|
||||
"notify": "Enviar Notificación",
|
||||
"recipient": "Destinatario",
|
||||
"message": "Mensaje"
|
||||
},
|
||||
"dropzone": {
|
||||
"drag_files": "Arrastra archivos aquí o haz clic para seleccionar",
|
||||
"drop_files": "Suelta los archivos para subirlos"
|
||||
},
|
||||
"permissions": {
|
||||
"read": "Lectura",
|
||||
"write": "Escritura",
|
||||
"reshare": "Recompartir"
|
||||
},
|
||||
"errors": {
|
||||
"file_not_found": "Archivo no encontrado",
|
||||
"folder_not_found": "Carpeta no encontrada",
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OxiCloud - Shared Resources</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-container">
|
||||
<a href="/" class="logo-container">
|
||||
<img src="/oxicloud-logo.svg" alt="OxiCloud Logo" class="logo">
|
||||
<h1>OxiCloud</h1>
|
||||
</a>
|
||||
<div class="header-actions">
|
||||
<div class="language-selector">
|
||||
<select id="language-selector">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="go-to-files" class="header-button">
|
||||
<span data-i18n="shared.backToFiles">Back to Files</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shared-page-container">
|
||||
<div class="shared-header">
|
||||
<h2 data-i18n="shared.pageTitle">Shared Resources</h2>
|
||||
<p data-i18n="shared.pageDescription">Manage your shared files and folders</p>
|
||||
</div>
|
||||
|
||||
<div class="shared-filters">
|
||||
<div class="filter-group">
|
||||
<label for="filter-type" data-i18n="shared.filterType">Type:</label>
|
||||
<select id="filter-type">
|
||||
<option value="all" data-i18n="shared.filterAll">All</option>
|
||||
<option value="file" data-i18n="shared.filterFiles">Files</option>
|
||||
<option value="folder" data-i18n="shared.filterFolders">Folders</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-by" data-i18n="shared.sortBy">Sort by:</label>
|
||||
<select id="sort-by">
|
||||
<option value="name" data-i18n="shared.sortByName">Name</option>
|
||||
<option value="date" data-i18n="shared.sortByDate">Date shared</option>
|
||||
<option value="expiration" data-i18n="shared.sortByExpiration">Expiration</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="shared-search" placeholder="Search shared items...">
|
||||
<button id="shared-search-btn"><span data-i18n="shared.search">Search</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shared-list-container">
|
||||
<table class="shared-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="shared.colName">Name</th>
|
||||
<th data-i18n="shared.colType">Type</th>
|
||||
<th data-i18n="shared.colDateShared">Date Shared</th>
|
||||
<th data-i18n="shared.colExpiration">Expiration</th>
|
||||
<th data-i18n="shared.colPermissions">Permissions</th>
|
||||
<th data-i18n="shared.colPassword">Password</th>
|
||||
<th data-i18n="shared.colActions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="shared-items-list">
|
||||
<!-- Shared items will be loaded here dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="empty-shared-state" class="empty-state" style="display:none;">
|
||||
<div class="empty-state-icon">📂</div>
|
||||
<h3 data-i18n="shared.emptyStateTitle">No shared resources yet</h3>
|
||||
<p data-i18n="shared.emptyStateDesc">When you share files or folders, they will appear here</p>
|
||||
<a href="/" class="button primary" data-i18n="shared.goToFiles">Go to Files</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Share Link Dialog (for editing existing shares) -->
|
||||
<div id="share-dialog" class="dialog">
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="share.dialogTitle">Share Link</h3>
|
||||
<button class="close-dialog-btn">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="share-item-info">
|
||||
<span id="share-dialog-icon" class="item-icon">📄</span>
|
||||
<span id="share-dialog-name" class="item-name">filename.ext</span>
|
||||
</div>
|
||||
|
||||
<div class="share-link-section">
|
||||
<label for="share-link-url" data-i18n="share.linkLabel">Share Link:</label>
|
||||
<div class="share-link-container">
|
||||
<input type="text" id="share-link-url" readonly>
|
||||
<button id="copy-link-btn" data-i18n="share.copyLink">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-settings">
|
||||
<div class="share-setting">
|
||||
<label data-i18n="share.permissions">Permissions:</label>
|
||||
<div class="permissions-options">
|
||||
<label>
|
||||
<input type="checkbox" id="permission-read" checked>
|
||||
<span data-i18n="share.permissionRead">Read</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="permission-write">
|
||||
<span data-i18n="share.permissionWrite">Write</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="permission-reshare">
|
||||
<span data-i18n="share.permissionReshare">Reshare</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-setting">
|
||||
<label for="share-password" data-i18n="share.password">Password Protection:</label>
|
||||
<div class="password-setting">
|
||||
<input type="checkbox" id="enable-password">
|
||||
<input type="password" id="share-password" placeholder="Enter password" disabled>
|
||||
<button id="generate-password" data-i18n="share.generatePassword">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-setting">
|
||||
<label for="share-expiration" data-i18n="share.expiration">Expiration Date:</label>
|
||||
<div class="expiration-setting">
|
||||
<input type="checkbox" id="enable-expiration">
|
||||
<input type="date" id="share-expiration" disabled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-actions">
|
||||
<button id="update-share-btn" class="button primary" data-i18n="share.update">Update Share</button>
|
||||
<button id="remove-share-btn" class="button danger" data-i18n="share.remove">Remove Share</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Notification Dialog -->
|
||||
<div id="share-notification-dialog" class="dialog">
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="share.notifyTitle">Send Notification</h3>
|
||||
<button class="close-dialog-btn">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="share-item-info">
|
||||
<span id="notify-dialog-icon" class="item-icon">📄</span>
|
||||
<span id="notify-dialog-name" class="item-name">filename.ext</span>
|
||||
</div>
|
||||
|
||||
<div class="notification-form">
|
||||
<div class="form-group">
|
||||
<label for="notification-email" data-i18n="share.notifyEmailLabel">Email Address:</label>
|
||||
<input type="email" id="notification-email" placeholder="Enter recipient email">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notification-message" data-i18n="share.notifyMessageLabel">Message (optional):</label>
|
||||
<textarea id="notification-message" placeholder="Add a personal message" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-actions">
|
||||
<button id="send-notification-btn" class="button primary" data-i18n="share.notifySend">Send Notification</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Banner -->
|
||||
<div id="notification-banner" class="notification-banner">
|
||||
<span id="notification-message"></span>
|
||||
<button id="close-notification" class="close-notification-btn">×</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/languageSelector.js"></script>
|
||||
<script src="/js/fileSharing.js"></script>
|
||||
<script src="/js/shared.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user