Guía completa para construir un sistema similar a Google Drive con reproducción de video, visualización de imágenes y documentos
Este manual detalla cómo construir un sistema de almacenamiento en la nube similar a Google Drive utilizando:
Para el backend de alto rendimiento con seguridad garantizada en tiempo de compilación.
Como base de datos flexible para almacenar metadatos de archivos y relaciones.
Para almacenamiento eficiente de archivos grandes directamente en MongoDB.
Framework web rápido y seguro para construir la API REST.
Para procesamiento de video y generación de miniaturas.
Documentación interactiva de la API para desarrolladores.
┌─────────────────────────────────────────────────────────────────────┐ │ Aplicación Cliente │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ API REST │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Autenticación│ │ Gestión de │ │ Streaming │ │Previsualiza-│ │ │ │ (JWT+Bcrypt) │ │ Archivos │ │ de Video │ │ ciones │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ │ ┌──────────────────┼──────────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │ MongoDB │ │ GridFS │ │ FFmpeg │ │ (Metadatos y │ │ (Almacenamiento │ │ (Procesamiento │ │ relaciones) │ │ de archivos) │ │ de multimedia) │ └───────────────────────┘ └───────────────────────┘ └───────────────────────┘
[package]
name = "rust-drive-api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.0"
actix-files = "0.6" # Para servir archivos estáticos
actix-multipart = "0.5" # Para manejar uploads
mongodb = { version = "2.0", features = ["sync", "gridfs"] }
bcrypt = "0.14" # Para hashing de contraseñas
jsonwebtoken = "8.0" # Para JWT
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
validator = { version = "2.0", features = ["derive"] } # Validación
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
futures = "0.3" # Para operaciones async
tokio = { version = "1.0", features = ["full"] } # Runtime async
mime = "0.3" # Para tipos MIME
mime_guess = "2.0" # Para detectar tipos de archivo
utoipa = { version = "2.0", features = ["actix_extras"] } # Swagger
utoipa-swagger-ui = "2.0" # UI Swagger
reqwest = { version = "0.11", features = ["json"] } # Para llamadas HTTP
bytes = "1.0" # Para manejo de bytes
lazy_static = "1.4" # Para variables globales
thiserror = "1.0" # Manejo de errores
log = "0.4" # Logging
env_logger = "0.9" # Logger
config = "0.13" # Configuración
dotenv = "0.15" # Variables de entorno
[server]
host = "127.0.0.1"
port = 8080
max_upload_size = "10GB" # Límite para uploads
temp_dir = "./tmp" # Directorio temporal
[database]
url = "mongodb://localhost:27017"
name = "drive_db"
timeout_ms = 5000
[auth]
jwt_secret = "your_very_secure_jwt_secret"
jwt_expires_in = 1440 # minutos (24 horas)
salt_rounds = 12 # Para bcrypt
[storage]
gridfs_bucket = "files" # Nombre del bucket GridFS
thumbnails_bucket = "thumbnails" # Para miniaturas
previews_bucket = "previews" # Para previsualizaciones
[ffmpeg]
path = "/usr/bin/ffmpeg" # Ruta al binario ffmpeg
thumbnail_size = "320x180" # Tamaño miniaturas video
video_qualities = ["480p", "720p", "1080p"] # Calidades para streaming
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Serialize, Deserialize};
use validator::Validate;
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub email: String,
#[serde(skip_serializing)]
pub password: String,
pub name: String,
pub storage_used: i64, // Bytes usados
pub storage_limit: i64, // Límite en bytes
pub is_active: bool,
pub is_admin: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Deserialize, Validate)]
pub struct NewUser {
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
#[validate(length(min = 2))]
pub name: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct LoginUser {
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
}
use mongodb::bson::{oid::ObjectId, DateTime, doc};
use serde::{Serialize, Deserialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct File {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub name: String,
pub path: String, // Ruta lógica en el sistema
pub size: i64, // Tamaño en bytes
pub mime_type: String,
pub file_id: ObjectId, // ID en GridFS
pub is_folder: bool,
pub parent_id: Option<ObjectId>, // Carpeta contenedora
pub owner_id: ObjectId, // Usuario propietario
pub shared_with: Vec<ObjectId>, // Usuarios con acceso
pub permissions: Vec<Permission>,
pub versions: Vec<FileVersion>,
pub thumbnail_id: Option<ObjectId>, // ID de miniatura en GridFS
pub preview_id: Option<ObjectId>, // ID de previsualización
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileVersion {
pub version: i32,
pub file_id: ObjectId,
pub size: i64,
pub created_at: DateTime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Permission {
pub user_id: ObjectId,
pub can_read: bool,
pub can_write: bool,
pub can_share: bool,
}
impl File {
pub fn collection(db: &mongodb::sync::Database) -> mongodb::sync::Collection<Self> {
db.collection("files")
}
pub fn get_full_path(&self) -> PathBuf {
PathBuf::from(&self.path).join(&self.name)
}
pub fn is_media(&self) -> bool {
self.mime_type.starts_with("image/") ||
self.mime_type.starts_with("video/") ||
self.mime_type.starts_with("audio/")
}
pub fn is_document(&self) -> bool {
self.mime_type == "application/pdf" ||
self.mime_type.starts_with("text/") ||
self.mime_type == "application/msword" ||
self.mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
}
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
use serde::{Serialize, Deserialize};
use mongodb::bson::oid::ObjectId;
use crate::config::AuthConfig;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // user_id
pub exp: usize, // expiry
pub is_admin: bool,
}
pub fn create_jwt_token(
user_id: ObjectId,
is_admin: bool,
auth_config: &AuthConfig,
) -> Result<String, jsonwebtoken::errors::Error> {
let expiration_time = Utc::now()
.checked_add_signed(Duration::minutes(auth_config.jwt_expires_in))
.expect("Invalid timestamp")
.timestamp();
let claims = Claims {
sub: user_id.to_hex(),
exp: expiration_time as usize,
is_admin,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(auth_config.jwt_secret.as_bytes()),
)
}
pub fn decode_jwt_token(
token: &str,
auth_config: &AuthConfig,
) -> Result<Claims, jsonwebtoken::errors::Error> {
decode::<Claims>(
token,
&DecodingKey::from_secret(auth_config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
}
use actix_web::{dev::ServiceRequest, Error, http};
use actix_web_httpauth::extractors::bearer::{BearerAuth, Config};
use jsonwebtoken::errors::ErrorKind;
use mongodb::bson::oid::ObjectId;
use crate::{
utils::auth::decode_jwt_token,
config::AuthConfig,
models::user::User,
errors::ApiError,
};
pub async fn validator(
req: ServiceRequest,
credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
let auth_config = req.app_data::<AuthConfig>()
.ok_or_else(|| {
let error = Error::from(ApiError::InternalServerError);
(error, req)
})?;
match decode_jwt_token(credentials.token(), auth_config) {
Ok(claims) => {
// Verificar que el usuario existe
let user_id = ObjectId::parse_str(&claims.sub)
.map_err(|_| {
let error = AuthenticationError::from(Config::default())
.with_error("Invalid user ID in token");
(error.into(), req)
})?;
let db = req.app_data::<mongodb::sync::Database>()
.ok_or_else(|| {
let error = Error::from(ApiError::InternalServerError);
(error, req)
})?;
let user_exists = User::collection(db)
.count_documents(doc! { "_id": user_id }, None)
.map_err(|_| {
let error = Error::from(ApiError::InternalServerError);
(error, req)
})? > 0;
if !user_exists {
let error = AuthenticationError::from(Config::default())
.with_error("User no longer exists");
return Err((error.into(), req));
}
// Añadir claims a las extensiones de la petición
req.extensions_mut().insert(claims);
Ok(req)
},
Err(e) => {
let config = req.app_data::<Config>().cloned().unwrap_or_default();
let error = match e.kind() {
ErrorKind::ExpiredSignature => {
AuthenticationError::from(config).with_error("Token expired")
},
_ => AuthenticationError::from(config).with_error("Invalid token"),
};
Err((error.into(), req))
}
}
}
use mongodb::{
sync::{Client, Database, GridFsBucket},
bson::{doc, oid::ObjectId},
options::GridFsBucketOptions,
};
use std::path::Path;
use crate::config::StorageConfig;
pub struct StorageService {
files_bucket: GridFsBucket,
thumbnails_bucket: GridFsBucket,
previews_bucket: GridFsBucket,
}
impl StorageService {
pub fn new(db: &Database, config: &StorageConfig) -> Self {
let files_options = GridFsBucketOptions::builder()
.bucket_name(&config.gridfs_bucket)
.build();
let thumbnails_options = GridFsBucketOptions::builder()
.bucket_name(&config.thumbnails_bucket)
.build();
let previews_options = GridFsBucketOptions::builder()
.bucket_name(&config.previews_bucket)
.build();
Self {
files_bucket: db.gridfs_bucket(files_options),
thumbnails_bucket: db.gridfs_bucket(thumbnails_options),
previews_bucket: db.gridfs_bucket(previews_options),
}
}
pub fn upload_file(
&self,
filename: &str,
mime_type: &str,
data: &[u8],
) -> Result<ObjectId, mongodb::error::Error> {
let mut upload_stream = self.files_bucket.open_upload_stream(filename, None);
upload_stream.write_all(data)?;
let file_id = upload_stream.finish()?;
Ok(file_id)
}
pub fn download_file(
&self,
file_id: ObjectId,
) -> Result<Vec<u8>, mongodb::error::Error> {
let mut cursor = self.files_bucket.open_download_stream(file_id)?;
let mut buffer = Vec::new();
cursor.read_to_end(&mut buffer)?;
Ok(buffer)
}
pub fn delete_file(&self, file_id: ObjectId) -> Result<(), mongodb::error::Error> {
self.files_bucket.delete(file_id)?;
Ok(())
}
// Métodos similares para thumbnails y previews...
}
use std::process::{Command, Stdio};
use std::path::Path;
use tempfile::NamedTempFile;
use crate::{config::FfmpegConfig, errors::ApiError};
pub struct VideoService {
ffmpeg_path: String,
thumbnail_size: String,
video_qualities: Vec<String>,
}
impl VideoService {
pub fn new(config: &FfmpegConfig) -> Self {
Self {
ffmpeg_path: config.path.clone(),
thumbnail_size: config.thumbnail_size.clone(),
video_qualities: config.video_qualities.clone(),
}
}
pub fn generate_thumbnail(
&self,
video_data: &[u8],
) -> Result<Vec<u8>, ApiError> {
// Crear archivo temporal para el video
let mut video_file = NamedTempFile::new()?;
video_file.write_all(video_data)?;
let video_path = video_file.path().to_str().unwrap();
// Crear archivo temporal para la miniatura
let thumbnail_file = NamedTempFile::new()?;
let thumbnail_path = thumbnail_file.path().to_str().unwrap();
// Ejecutar ffmpeg para generar miniatura
let status = Command::new(&self.ffmpeg_path)
.args(&[
"-i", video_path,
"-ss", "00:00:01", // Capturar en el segundo 1
"-vframes", "1",
"-s", &self.thumbnail_size,
"-f", "image2",
thumbnail_path,
])
.status()?;
if !status.success() {
return Err(ApiError::new(500, "Failed to generate thumbnail"));
}
// Leer la miniatura generada
let thumbnail_data = std::fs::read(thumbnail_path)?;
Ok(thumbnail_data)
}
pub fn transcode_video(
&self,
video_data: &[u8],
quality: &str,
) -> Result<Vec<u8>, ApiError> {
// Implementar transcodificación a diferentes calidades
// Similar a generate_thumbnail pero con parámetros de transcodificación
// ...
todo!()
}
}
use std::process::{Command, Stdio};
use std::io::{Write, Read};
use tempfile::NamedTempFile;
use crate::errors::ApiError;
pub struct PreviewService;
impl PreviewService {
pub fn generate_pdf_preview(
&self,
pdf_data: &[u8],
) -> Result<Vec<u8>, ApiError> {
// Usar pdftoppm o similar para generar imagen de la primera página
let mut pdf_file = NamedTempFile::new()?;
pdf_file.write_all(pdf_data)?;
let pdf_path = pdf_file.path().to_str().unwrap();
let output_file = NamedTempFile::new()?;
let output_path = output_file.path().to_str().unwrap();
let status = Command::new("pdftoppm")
.args(&[
"-f", "1", // Primera página
"-l", "1", // Solo una página
"-jpeg",
pdf_path,
output_path,
])
.status()?;
if !status.success() {
return Err(ApiError::new(500, "Failed to generate PDF preview"));
}
let preview_data = std::fs::read(format!("{}-1.jpg", output_path))?;
Ok(preview_data)
}
pub fn generate_archive_preview(
&self,
archive_data: &[u8],
) -> Result<Vec<String>, ApiError> {
// Listar contenido de archivos comprimidos (ZIP, RAR, etc.)
let mut archive_file = NamedTempFile::new()?;
archive_file.write_all(archive_data)?;
let archive_path = archive_file.path().to_str().unwrap();
let output = Command::new("unzip")
.args(&["-l", archive_path])
.output()?;
if !output.status.success() {
return Err(ApiError::new(500, "Failed to list archive contents"));
}
let contents = String::from_utf8(output.stdout)?;
let files = contents.lines()
.skip(3) // Saltar líneas de encabezado
.take(10) // Mostrar solo los primeros 10 archivos
.map(|s| s.trim().to_string())
.collect();
Ok(files)
}
}
use actix_web::{web, Scope};
use crate::handlers::files::{
upload_file_handler,
download_file_handler,
stream_video_handler,
get_file_preview_handler,
list_files_handler,
create_folder_handler,
share_file_handler,
};
pub fn files_routes() -> Scope {
web::scope("/files")
.service(
web::resource("")
.route(web::get().to(list_files_handler))
.route(web::post().to(upload_file_handler))
)
.service(
web::resource("/folder")
.route(web::post().to(create_folder_handler))
)
.service(
web::resource("/{file_id}")
.route(web::get().to(download_file_handler))
)
.service(
web::resource("/{file_id}/preview")
.route(web::get().to(get_file_preview_handler))
)
.service(
web::resource("/{file_id}/stream")
.route(web::get().to(stream_video_handler))
)
.service(
web::resource("/{file_id}/share")
.route(web::post().to(share_file_handler))
)
}
use actix_web::{web, HttpResponse, HttpRequest};
use actix_multipart::Multipart;
use futures_util::StreamExt;
use mongodb::bson::{doc, DateTime};
use std::path::Path;
use crate::{
models::file::File,
services::{storage::StorageService, video::VideoService, preview::PreviewService},
utils::auth::Claims,
errors::ApiError,
AppState,
};
pub async fn upload_file_handler(
state: web::Data<AppState>,
req: HttpRequest,
mut payload: Multipart,
) -> Result<HttpResponse, ApiError> {
// Verificar autenticación
let claims = req.extensions().get::<Claims>()
.ok_or(ApiError::Unauthorized("Invalid token".into()))?;
let user_id = mongodb::bson::oid::ObjectId::parse_str(&claims.sub)
.map_err(|_| ApiError::BadRequest("Invalid user ID".into()))?;
// Procesar multipart form
while let Some(item) = payload.next().await {
let mut field = item?;
let content_type = field.content_disposition().unwrap();
let filename = content_type.get_filename().unwrap();
// Validar tamaño del archivo
let mut file_data = Vec::new();
while let Some(chunk) = field.next().await {
file_data.extend_from_slice(&chunk?);
// Verificar límite de almacenamiento
if file_data.len() as i64 > state.config.server.max_upload_size {
return Err(ApiError::PayloadTooLarge);
}
}
// Determinar tipo MIME
let mime_type = mime_guess::from_path(filename).first_or_octet_stream();
// Almacenar archivo en GridFS
let file_id = state.storage.upload_file(filename, &mime_type.to_string(), &file_data)?;
// Generar miniaturas y previsualizaciones para tipos específicos
let thumbnail_id = if mime_type.type_() == mime::IMAGE || mime_type.type_() == mime::VIDEO {
let thumbnail_data = if mime_type.type_() == mime::IMAGE {
// Redimensionar imagen para miniatura
image::load_from_memory(&file_data)?
.resize(320, 180, image::imageops::FilterType::Lanczos3)
.to_bytes()
} else {
// Generar miniatura de video
state.video.generate_thumbnail(&file_data)?
};
Some(state.storage.upload_thumbnail(
&format!("thumb_{}", filename),
"image/jpeg",
&thumbnail_data,
)?)
} else {
None
};
// Generar previsualización para documentos
let preview_id = if mime_type == mime::APPLICATION_PDF {
let preview_data = state.preview.generate_pdf_preview(&file_data)?;
Some(state.storage.upload_preview(
&format!("preview_{}.jpg", filename),
"image/jpeg",
&preview_data,
)?)
} else {
None
};
// Guardar metadatos en MongoDB
let file = File {
id: None,
name: filename.to_string(),
path: "/".to_string(), // Carpeta raíz por defecto
size: file_data.len() as i64,
mime_type: mime_type.to_string(),
file_id,
is_folder: false,
parent_id: None,
owner_id: user_id,
shared_with: Vec::new(),
permissions: Vec::new(),
versions: Vec::new(),
thumbnail_id,
preview_id,
created_at: DateTime::now(),
updated_at: DateTime::now(),
};
File::collection(&state.db).insert_one(file, None)?;
}
Ok(HttpResponse::Ok().json(json!({"status": "success"})))
}
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
paths(
handlers::auth::login_handler,
handlers::auth::register_handler,
handlers::files::upload_file_handler,
handlers::files::download_file_handler,
handlers::files::stream_video_handler,
handlers::files::get_file_preview_handler,
),
components(
schemas(
models::user::User,
models::file::File,
models::file::Permission,
errors::ApiError,
)
),
tags(
(name = "Auth", description = "Authentication endpoints"),
(name = "Files", description = "File management endpoints"),
)
)]
struct ApiDoc;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// ... configuración previa ...
let app = HttpServer::new(move || {
App::new()
.app_data(web::Data::new(app_state.clone()))
.configure(routes::config)
.service(
SwaggerUi::new("/swagger-ui/{_:.*}")
.url("/api-docs/openapi.json", ApiDoc::openapi()),
)
})
.bind((config.server.host, config.server.port))?
.run();
app.await
}
# Stage 1: Builder
FROM rust:1.60 as builder
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
cmake \
libssl-dev \
pkg-config \
ffmpeg \
poppler-utils \ # Para pdftoppm
unzip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
# Build estático con musl para imagen minimal
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release
# Stage 2: Runtime image
FROM debian:buster-slim
# Instalar dependencias de runtime
RUN apt-get update && apt-get install -y \
ffmpeg \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# Crear directorio para archivos temporales
RUN mkdir -p /app/tmp
ENV TEMP_DIR=/app/tmp
# Copiar binario
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-drive-api /usr/local/bin/rust-drive-api
# Variables de entorno
ENV RUST_LOG=info
ENV APP__DATABASE__URL=mongodb://mongo:27017
ENV APP__DATABASE__NAME=drive_db
ENV APP__AUTH__JWT_SECRET=your_secure_secret_key
ENV APP__FFMPEG__PATH=/usr/bin/ffmpeg
EXPOSE 8080
CMD ["rust-drive-api"]
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mongo
volumes:
- ./data/tmp:/app/tmp # Directorio temporal
environment:
- APP__DATABASE__URL=mongodb://mongo:27017
- APP__DATABASE__NAME=drive_db
- APP__AUTH__JWT_SECRET=your_secure_secret_key
- APP__AUTH__SALT_ROUNDS=12
- APP__STORAGE__GRIDFS_BUCKET=files
- APP__STORAGE__THUMBNAILS_BUCKET=thumbnails
- APP__STORAGE__PREVIEWS_BUCKET=previews
- APP__FFMPEG__PATH=/usr/bin/ffmpeg
- APP__FFMPEG__THUMBNAIL_SIZE=320x180
restart: unless-stopped
mongo:
image: mongo:5.0
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
volumes:
- ./data/mongo:/data/db
ports:
- "27017:27017"
restart: unless-stopped
mongo-express:
image: mongo-express:0.54
restart: always
ports:
- "8081:8081"
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=admin
- ME_CONFIG_MONGODB_ADMINPASSWORD=password
- ME_CONFIG_MONGODB_SERVER=mongo
depends_on:
- mongo
volumes:
data: