From deac2ba0a381e60fae8661660b453e9b6027f4e6 Mon Sep 17 00:00:00 2001 From: Per Stark Date: Tue, 24 Sep 2024 22:01:12 +0200 Subject: [PATCH] wip file upload and storage to disk --- Cargo.lock | 64 +++++++ Cargo.toml | 1 + flake.nix | 3 + hello_world.txt | 1 + src/lib.rs | 1 + src/models/file_info.rs | 357 ++++++++++++++++++++++++++++++++++++++++ src/models/files.rs | 109 ------------ src/models/ingress.rs | 2 +- src/models/mod.rs | 2 +- src/redis/client.rs | 166 +++++++++++++++++++ src/redis/mod.rs | 1 + src/routes/file.rs | 152 +++++++++++++++-- src/server.rs | 11 +- test.txt | 1 + 14 files changed, 738 insertions(+), 133 deletions(-) create mode 100644 hello_world.txt create mode 100644 src/models/file_info.rs delete mode 100644 src/models/files.rs create mode 100644 src/redis/client.rs create mode 100644 src/redis/mod.rs create mode 100644 test.txt diff --git a/Cargo.lock b/Cargo.lock index 9b978d8..0296e88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,12 @@ version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "asn1-rs" version = "0.6.2" @@ -548,6 +554,20 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -917,6 +937,7 @@ checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", @@ -1680,6 +1701,29 @@ dependencies = [ "futures-io", ] +[[package]] +name = "redis" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e86f5670bd8b028edfb240f0616cad620705b31ec389d55e4f3da2c38dcd48" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.7", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.3" @@ -1992,6 +2036,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.8" @@ -2267,6 +2317,19 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.4.13" @@ -2751,6 +2814,7 @@ dependencies = [ "lapin", "mime", "mime_guess", + "redis", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 9dbebd0..941e29d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ futures-lite = "2.3.0" lapin = { version = "2.5.0", features = ["serde_json"] } mime = "0.3.17" mime_guess = "2.0.5" +redis = { version = "0.27.2", features = ["aio", "tokio-comp"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" sha2 = "0.10.8" diff --git a/flake.nix b/flake.nix index d37c403..9e220a0 100644 --- a/flake.nix +++ b/flake.nix @@ -41,6 +41,9 @@ languages.rust.enable = true; services = { + redis = { + enable = true; + }; rabbitmq = { enable = true; plugins = ["tracing"]; diff --git a/hello_world.txt b/hello_world.txt new file mode 100644 index 0000000..557db03 --- /dev/null +++ b/hello_world.txt @@ -0,0 +1 @@ +Hello World diff --git a/src/lib.rs b/src/lib.rs index 9c9e829..7545dec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod models; pub mod rabbitmq; +pub mod redis; pub mod routes; pub mod utils; diff --git a/src/models/file_info.rs b/src/models/file_info.rs new file mode 100644 index 0000000..56c6be9 --- /dev/null +++ b/src/models/file_info.rs @@ -0,0 +1,357 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use axum_typed_multipart::FieldData; +use mime_guess::from_path; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + io::{BufReader, Read}, + path::{Path, PathBuf}, +}; +use tempfile::NamedTempFile; +use thiserror::Error; +use tracing::info; +use uuid::Uuid; + +use crate::redis::client::RedisClient; + +/// Represents metadata and storage information for a file. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileInfo { + pub uuid: Uuid, + pub sha256: String, + pub path: String, + pub mime_type: String, +} + +#[derive(Error, Debug)] +pub enum FileError { + #[error("IO error occurred: {0}")] + Io(#[from] std::io::Error), + + #[error("UTF-8 conversion error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), + + #[error("MIME type detection failed for input: {0}")] + MimeDetection(String), + + #[error("Unsupported MIME type: {0}")] + UnsupportedMime(String), + + #[error("Redis error: {0}")] + RedisError(#[from] crate::redis::client::RedisError), + + #[error("File not found for UUID: {0}")] + FileNotFound(Uuid), + + #[error("Duplicate file detected with SHA256: {0}")] + DuplicateFile(String), + + #[error("Hash collision detected")] + HashCollision, + + #[error("Invalid UUID format: {0}")] + InvalidUuid(String), + + #[error("File name missing in metadata")] + MissingFileName, + + #[error("Failed to persist file: {0}")] + PersistError(String), + + #[error("Serialization error: {0}")] + SerializationError(String), + + #[error("Deserialization error: {0}")] + DeserializationError(String), + + // Add more error variants as needed. +} + +impl IntoResponse for FileError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + FileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"), + FileError::Utf8(_) => (StatusCode::BAD_REQUEST, "Invalid UTF-8 data"), + FileError::MimeDetection(_) => (StatusCode::BAD_REQUEST, "MIME type detection failed"), + FileError::UnsupportedMime(_) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, "Unsupported MIME type"), + FileError::RedisError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Redis error"), + FileError::FileNotFound(_) => (StatusCode::NOT_FOUND, "File not found"), + FileError::DuplicateFile(_) => (StatusCode::CONFLICT, "Duplicate file detected"), + FileError::HashCollision => (StatusCode::INTERNAL_SERVER_ERROR, "Hash collision detected"), + FileError::InvalidUuid(_) => (StatusCode::BAD_REQUEST, "Invalid UUID format"), + FileError::MissingFileName => (StatusCode::BAD_REQUEST, "Missing file name in metadata"), + FileError::PersistError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to persist file"), + FileError::SerializationError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error"), + FileError::DeserializationError(_) => (StatusCode::BAD_REQUEST, "Deserialization error"), + }; + + let body = Json(json!({ + "error": error_message, + })); + + (status, body).into_response() + } +} + +impl FileInfo { + /// Creates a new `FileInfo` instance from uploaded field data. + /// + /// # Arguments + /// + /// * `field_data` - The uploaded file data. + /// + /// # Returns + /// + /// * `Result` - The created `FileInfo` or an error. + pub async fn new(field_data: FieldData, redis_client: &RedisClient) -> Result { + let file = field_data.contents; // NamedTempFile + let metadata = field_data.metadata; + + // Extract file name from metadata + let file_name = metadata.file_name.ok_or(FileError::MissingFileName)?; + info!("File name: {:?}", file_name); + + // Calculate SHA256 hash of the file + let sha = Self::get_sha(&file).await?; + info!("SHA256: {:?}", sha); + + // Check if SHA exists in Redis + if let Some(existing_file_info) = redis_client.get_file_info_by_sha(&sha).await? { + info!("Duplicate file detected with SHA256: {}", sha); + return Ok(existing_file_info); + } + + // Generate a new UUID + let uuid = Uuid::new_v4(); + info!("UUID: {:?}", uuid); + + // Sanitize file name + let sanitized_file_name = sanitize_file_name(&file_name); + info!("Sanitized file name: {:?}", sanitized_file_name); + + // Persist the file to the filesystem + let persisted_path = Self::persist_file(&uuid, file, &sanitized_file_name).await?; + + // Guess the MIME type + let mime_type = Self::guess_mime_type(&persisted_path); + info!("Mime type: {:?}", mime_type); + + // Construct the FileInfo object + let file_info = FileInfo { + uuid, + sha256: sha.clone(), + path: persisted_path.to_string_lossy().to_string(), + mime_type, + }; + + // Store FileInfo in Redis with SHA256 as key + redis_client.set_file_info(&sha, &file_info).await?; + + // Map UUID to SHA256 in Redis + redis_client.set_sha_uuid_mapping(&uuid, &sha).await?; + + Ok(file_info) + } + + /// Retrieves `FileInfo` based on UUID. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file. + /// + /// # Returns + /// + /// * `Result` - The `FileInfo` or an error. + pub async fn get(uuid: Uuid, redis_client: &RedisClient) -> Result { + // Fetch SHA256 from UUID mapping + let sha = redis_client.get_sha_by_uuid(&uuid).await? + .ok_or(FileError::FileNotFound(uuid))?; + + // Retrieve FileInfo by SHA256 + let file_info = redis_client.get_file_info_by_sha(&sha).await? + .ok_or(FileError::FileNotFound(uuid))?; + + Ok(file_info) + } + + /// Updates an existing file identified by UUID with new file data. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file to update. + /// * `new_field_data` - The new file data. + /// * `redis_client` - Reference to the RedisClient. + /// + /// # Returns + /// + /// * `Result` - The updated `FileInfo` or an error. + pub async fn update(uuid: Uuid, new_field_data: FieldData, redis_client: &RedisClient) -> Result { + let new_file = new_field_data.contents; + let new_metadata = new_field_data.metadata; + + // Extract new file name + let new_file_name = new_metadata.file_name.ok_or(FileError::MissingFileName)?; + + // Calculate SHA256 of the new file + let new_sha = Self::get_sha(&new_file).await?; + + // Check if the new SHA already exists + if let Some(existing_file_info) = redis_client.get_file_info_by_sha(&new_sha).await? { + info!("Duplicate file detected with SHA256: {}", new_sha); + return Ok(existing_file_info); + } + + // Sanitize new file name + let sanitized_new_file_name = sanitize_file_name(&new_file_name); + + // Persist the new file + let new_persisted_path = Self::persist_file(&uuid, new_file, &sanitized_new_file_name).await?; + + // Guess the new MIME type + let new_mime_type = Self::guess_mime_type(&new_persisted_path); + + // Retrieve existing FileInfo to get old SHA + let old_file_info = Self::get(uuid, redis_client).await?; + + // Update FileInfo + let updated_file_info = FileInfo { + uuid, + sha256: new_sha.clone(), + path: new_persisted_path.to_string_lossy().to_string(), + mime_type: new_mime_type, + }; + + // Update Redis: Remove old SHA entry and add new SHA entry + redis_client.delete_file_info(&old_file_info.sha256).await?; + redis_client.set_file_info(&new_sha, &updated_file_info).await?; + redis_client.set_sha_uuid_mapping(&uuid, &new_sha).await?; + + // Optionally, delete the old file from the filesystem if it's no longer referenced + // This requires reference counting or checking if other FileInfo entries point to the same SHA + // For simplicity, this step is omitted. + + Ok(updated_file_info) + } + + /// Deletes a file and its corresponding metadata based on UUID. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file to delete. + /// * `redis_client` - Reference to the RedisClient. + /// + /// # Returns + /// + /// * `Result<(), FileError>` - Empty result or an error. + pub async fn delete(uuid: Uuid, redis_client: &RedisClient) -> Result<(), FileError> { + // Retrieve FileInfo to get SHA256 and path + let file_info = Self::get(uuid, redis_client).await?; + + // Delete the file from the filesystem + let file_path = Path::new(&file_info.path); + if file_path.exists() { + tokio::fs::remove_file(file_path).await.map_err(FileError::Io)?; + info!("Deleted file at path: {}", file_info.path); + } else { + info!("File path does not exist, skipping deletion: {}", file_info.path); + } + + // Delete the FileInfo from Redis + redis_client.delete_file_info(&file_info.sha256).await?; + + // Delete the UUID to SHA mapping + redis_client.delete_sha_uuid_mapping(&uuid).await?; + + // Remove the UUID directory if empty + let uuid_dir = file_path.parent().ok_or(FileError::FileNotFound(uuid))?; + if uuid_dir.exists() { + let mut entries = tokio::fs::read_dir(uuid_dir).await.map_err(FileError::Io)?; + if entries.next_entry().await?.is_none() { + tokio::fs::remove_dir(uuid_dir).await.map_err(FileError::Io)?; + info!("Deleted empty UUID directory: {:?}", uuid_dir); + } + } + + Ok(()) + } + + /// Persists the file to the filesystem under `./data/{uuid}/{file_name}`. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file. + /// * `file` - The temporary file to persist. + /// * `file_name` - The sanitized file name. + /// + /// # Returns + /// + /// * `Result` - The persisted file path or an error. + async fn persist_file(uuid: &Uuid, file: NamedTempFile, file_name: &str) -> Result { + let base_dir = Path::new("./data"); + let uuid_dir = base_dir.join(uuid.to_string()); + + // Create the UUID directory if it doesn't exist + tokio::fs::create_dir_all(&uuid_dir).await.map_err(FileError::Io)?; + + // Define the final file path + let final_path = uuid_dir.join(file_name); + info!("Final path: {:?}", final_path); + + // Persist the temporary file to the final path + file.persist(&final_path).map_err(|e| FileError::PersistError(e.to_string()))?; + + info!("Persisted file to {:?}", final_path); + + Ok(final_path) + } + + /// Calculates the SHA256 hash of the given file. + /// + /// # Arguments + /// + /// * `file` - The file to hash. + /// + /// # Returns + /// + /// * `Result` - The SHA256 hash as a hex string or an error. + async fn get_sha(file: &NamedTempFile) -> Result { + let mut reader = BufReader::new(file.as_file()); + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; // 8KB buffer + + loop { + let n = reader.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + let digest = hasher.finalize(); + Ok(format!("{:x}", digest)) + } + + /// Guesses the MIME type based on the file extension. + /// + /// # Arguments + /// + /// * `path` - The path to the file. + /// + /// # Returns + /// + /// * `String` - The guessed MIME type as a string. + fn guess_mime_type(path: &Path) -> String { + from_path(path) + .first_or(mime::APPLICATION_OCTET_STREAM) + .to_string() + } +} + +/// Sanitizes the file name to prevent security vulnerabilities like directory traversal. +/// Replaces any non-alphanumeric characters (excluding '.' and '_') with underscores. +fn sanitize_file_name(file_name: &str) -> String { + file_name.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '_' { c } else { '_' }) + .collect() +} diff --git a/src/models/files.rs b/src/models/files.rs deleted file mode 100644 index 99ebbc5..0000000 --- a/src/models/files.rs +++ /dev/null @@ -1,109 +0,0 @@ -use axum_typed_multipart::{FieldData, TryFromMultipart}; -use mime_guess::from_path; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{io::{BufReader, Read}, path::Path}; -use tempfile::NamedTempFile; -use thiserror::Error; -use tracing::info; -use url::Url; -use uuid::Uuid; - -/// Error types for file and content handling. -#[derive(Error, Debug)] -pub enum FileError{ - #[error("IO error occurred: {0}")] - Io(#[from] std::io::Error), - - #[error("MIME type detection failed for input: {0}")] - MimeDetection(String), - - #[error("Unsupported MIME type: {0}")] - UnsupportedMime(String), -} - -#[derive(Debug, TryFromMultipart)] -pub struct FileUploadRequest { - #[form_data(limit = "unlimited")] - pub file: FieldData, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct FileInfo { - pub uuid: Uuid, - pub sha256: String, - pub path: String, - pub mime_type: String, -} - -impl FileInfo { - pub async fn new(file: NamedTempFile) -> Result { - // Calculate SHA based on file - let sha = Self::get_sha(&file).await?; - // Check if SHA exists in redis db - // If so, return existing FileInfo - // Generate UUID - // Persist file with uuid as path - // Guess the mime_type - // Construct the object - // file.persist("./data/{id}"); - Ok(FileInfo { uuid: Uuid::new_v4(), sha256: sha, path:String::new(), mime_type:String::new() }) - } - - pub async fn get(id: String) -> Result { - // Get the SHA based on file in uuid path - // Check if SHA exists in redis - // If so, return FileInfo - // Else return error - Ok(FileInfo { uuid: Uuid::new_v4(), sha256: String::new() , path:String::new(), mime_type:String::new() }) - } - - pub async fn update(id: String, file: NamedTempFile) -> Result { - // Calculate SHA based on file - // Check if SHA exists in redis - // If so, return existing FileInfo - // Use the submitted UUID - // Replace the old file with uuid as path - // Guess the mime_type - // Construct the object - Ok(FileInfo { uuid: Uuid::new_v4(), sha256: String::new() , path:String::new(), mime_type:String::new() }) - } - - pub async fn delete(id: String) -> Result<(), FileError> { - // Get the SHA based on file in uuid path - // Remove the entry from redis db - Ok(()) - } - - async fn get_sha(file: &NamedTempFile) -> Result { - let input = file.as_file(); - let mut reader = BufReader::new(input); - let digest = { - let mut hasher = Sha256::new(); - let mut buffer = [0; 1024]; - loop { - let count = reader.read(&mut buffer)?; - if count == 0 { break } - hasher.update(&buffer[..count]); - } - hasher.finalize() - }; - - Ok(format!("{:X}", digest)) - } -} - // let input = File::open(path)?; - // let mut reader = BufReader::new(input); - - // let digest = { - // let mut hasher = Sha256::new(); - // let mut buffer = [0; 1024]; - // loop { - // let count = reader.read(&mut buffer)?; - // if count == 0 { break } - // hasher.update(&buffer[..count]); - // } - // hasher.finalize() - // }; - // Ok(HEXLOWER.encode(digest.as_ref())) - diff --git a/src/models/ingress.rs b/src/models/ingress.rs index 85e45e9..b7442d8 100644 --- a/src/models/ingress.rs +++ b/src/models/ingress.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::info; use url::Url; -use super::files::FileInfo; +use super::file_info::FileInfo; #[derive(Debug, Deserialize, Serialize)] pub enum Content { diff --git a/src/models/mod.rs b/src/models/mod.rs index 4fa5bf3..f83e89b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,2 +1,2 @@ -pub mod files; +pub mod file_info; pub mod ingress; diff --git a/src/redis/client.rs b/src/redis/client.rs new file mode 100644 index 0000000..46e66fe --- /dev/null +++ b/src/redis/client.rs @@ -0,0 +1,166 @@ +use redis::AsyncCommands; +use thiserror::Error; +use uuid::Uuid; + +use crate::models::file_info::FileInfo; + +/// Represents errors that can occur during Redis operations. +#[derive(Error, Debug)] +pub enum RedisError { + #[error("Redis connection error: {0}")] + ConnectionError(String), + + #[error("Redis command error: {0}")] + CommandError(String), + + // Add more error variants as needed. +} + +/// Provides Redis-related operations for `FileInfo`. +pub struct RedisClient { + redis_url: String, +} + +impl RedisClient { + /// Creates a new instance of `RedisClient`. + /// + /// # Arguments + /// + /// * `redis_url` - The Redis server URL (e.g., "redis://127.0.0.1/"). + /// + /// # Returns + /// + /// * `Self` - A new `RedisClient` instance. + pub fn new(redis_url: &str) -> Self { + Self { + redis_url: redis_url.to_string(), + } + } + + /// Establishes a new multiplexed asynchronous connection to Redis. + /// + /// # Returns + /// + /// * `MultiplexedConnection` - The established connection. + pub async fn get_connection(&self) -> Result { + let client = redis::Client::open(self.redis_url.clone()) + .map_err(|e| RedisError::ConnectionError(e.to_string()))?; + let conn = client + .get_multiplexed_async_connection() + .await + .map_err(|e| RedisError::ConnectionError(e.to_string()))?; + Ok(conn) + } + + /// Stores `FileInfo` in Redis using SHA256 as the key. + /// + /// # Arguments + /// + /// * `sha256` - The SHA256 hash of the file. + /// * `file_info` - The `FileInfo` object to store. + /// + /// # Returns + /// + /// * `Result<(), RedisError>` - Empty result or an error. + pub async fn set_file_info(&self, sha256: &str, file_info: &FileInfo) -> Result<(), RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("file_info:{}", sha256); + let value = serde_json::to_string(file_info) + .map_err(|e| RedisError::CommandError(e.to_string()))?; + conn.set(key, value).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(()) + } + + /// Retrieves `FileInfo` from Redis using SHA256 as the key. + /// + /// # Arguments + /// + /// * `sha256` - The SHA256 hash of the file. + /// + /// # Returns + /// + /// * `Result, RedisError>` - The `FileInfo` if found, otherwise `None`, or an error. + pub async fn get_file_info_by_sha(&self, sha256: &str) -> Result, RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("file_info:{}", sha256); + let value: Option = conn.get(key).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + if let Some(json_str) = value { + let file_info: FileInfo = serde_json::from_str(&json_str) + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(Some(file_info)) + } else { + Ok(None) + } + } + + /// Deletes `FileInfo` from Redis using SHA256 as the key. + /// + /// # Arguments + /// + /// * `sha256` - The SHA256 hash of the file. + /// + /// # Returns + /// + /// * `Result<(), RedisError>` - Empty result or an error. + pub async fn delete_file_info(&self, sha256: &str) -> Result<(), RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("file_info:{}", sha256); + conn.del(key).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(()) + } + + /// Sets a mapping from UUID to SHA256. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file. + /// * `sha256` - The SHA256 hash of the file. + /// + /// # Returns + /// + /// * `Result<(), RedisError>` - Empty result or an error. + pub async fn set_sha_uuid_mapping(&self, uuid: &Uuid, sha256: &str) -> Result<(), RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("uuid_sha:{}", uuid); + conn.set(key, sha256).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(()) + } + + /// Retrieves the SHA256 hash associated with a given UUID. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file. + /// + /// # Returns + /// + /// * `Result, RedisError>` - The SHA256 hash if found, otherwise `None`, or an error. + pub async fn get_sha_by_uuid(&self, uuid: &Uuid) -> Result, RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("uuid_sha:{}", uuid); + let sha: Option = conn.get(key).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(sha) + } + + /// Deletes the UUID to SHA256 mapping from Redis. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the file. + /// + /// # Returns + /// + /// * `Result<(), RedisError>` - Empty result or an error. + pub async fn delete_sha_uuid_mapping(&self, uuid: &Uuid) -> Result<(), RedisError> { + let mut conn = self.get_connection().await?; + let key = format!("uuid_sha:{}", uuid); + conn.del(key).await + .map_err(|e| RedisError::CommandError(e.to_string()))?; + Ok(()) + } +} diff --git a/src/redis/mod.rs b/src/redis/mod.rs new file mode 100644 index 0000000..b9babe5 --- /dev/null +++ b/src/redis/mod.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/src/routes/file.rs b/src/routes/file.rs index 14501ce..ef764b1 100644 --- a/src/routes/file.rs +++ b/src/routes/file.rs @@ -1,22 +1,138 @@ -use axum::response::{IntoResponse, Response}; -use axum_typed_multipart::TypedMultipart; +// === Contents of ./routes/file.rs === -use crate::models::files::FileUploadRequest; +use axum::{ + extract::Path, + response::IntoResponse, + Json, + +}; +use axum_typed_multipart::{TypedMultipart, FieldData, TryFromMultipart}; +use serde_json::json; +use tempfile::NamedTempFile; +use tracing::{error, info}; +use uuid::Uuid; -// async fn upload_asset( -// TypedMultipart(UploadAssetRequest { image, author }): TypedMultipart, -// ) -> StatusCode { -// let file_name = image.metadata.file_name.unwrap_or(String::from("data.bin")); -// let path = Path::new("/tmp").join(author).join(file_name); +use crate::{ + models::file_info::{FileError, FileInfo}, + redis::client::RedisClient, + rabbitmq::publisher::RabbitMQProducer, +}; -// match image.contents.persist(path) { -// Ok(_) => StatusCode::CREATED, -// Err(_) => StatusCode::INTERNAL_SERVER_ERROR, -// } -// } -pub async fn upload_handler(TypedMultipart(input): TypedMultipart) -> Response { - // let file_name = input.file.metadata.file_name.unwrap_or("newstring".to_string()); - // +#[derive(Debug, TryFromMultipart)] +pub struct FileUploadRequest { + #[form_data(limit = "100000")] // Example limit: ~100 KB + pub file: FieldData, +} - "Successfully processed".to_string().into_response() -} +/// Handler to upload a new file. +/// +/// Route: POST /file +pub async fn upload_handler( + TypedMultipart(input): TypedMultipart, +) -> Result { + info!("Received an upload request"); + + // Initialize a new RedisClient instance + let redis_client = RedisClient::new("redis://127.0.0.1/"); + + // Process the file upload + let file_info = FileInfo::new(input.file, &redis_client).await?; + + // Prepare the response JSON + let response = json!({ + "uuid": file_info.uuid, + "sha256": file_info.sha256, + "path": file_info.path, + "mime_type": file_info.mime_type, + }); + + info!("File uploaded successfully: {:?}", file_info); + + // Return the response with HTTP 200 + Ok((axum::http::StatusCode::OK, Json(response))) +} + +/// Handler to retrieve file information by UUID. +/// +/// Route: GET /file/:uuid +pub async fn get_file_handler( + Path(uuid_str): Path, +) -> Result { + // Parse UUID + let uuid = Uuid::parse_str(&uuid_str).map_err(|_| FileError::InvalidUuid(uuid_str.clone()))?; + + // Initialize RedisClient + let redis_client = RedisClient::new("redis://127.0.0.1/"); + + // Retrieve FileInfo + let file_info = FileInfo::get(uuid, &redis_client).await?; + + // Prepare the response JSON + let response = json!({ + "uuid": file_info.uuid, + "sha256": file_info.sha256, + "path": file_info.path, + "mime_type": file_info.mime_type, + }); + + info!("Retrieved FileInfo: {:?}", file_info); + + // Return the response with HTTP 200 + Ok((axum::http::StatusCode::OK, Json(response))) +} + +/// Handler to update an existing file by UUID. +/// +/// Route: PUT /file/:uuid +pub async fn update_file_handler( + Path(uuid_str): Path, + TypedMultipart(input): TypedMultipart, +) -> Result { + // Parse UUID + let uuid = Uuid::parse_str(&uuid_str).map_err(|_| FileError::InvalidUuid(uuid_str.clone()))?; + + // Initialize RedisClient + let redis_client = RedisClient::new("redis://127.0.0.1/"); + + // Update the file + let updated_file_info = FileInfo::update(uuid, input.file, &redis_client).await?; + + // Prepare the response JSON + let response = json!({ + "uuid": updated_file_info.uuid, + "sha256": updated_file_info.sha256, + "path": updated_file_info.path, + "mime_type": updated_file_info.mime_type, + }); + + info!("File updated successfully: {:?}", updated_file_info); + + // Return the response with HTTP 200 + Ok((axum::http::StatusCode::OK, Json(response))) +} + +/// Handler to delete a file by UUID. +/// +/// Route: DELETE /file/:uuid +pub async fn delete_file_handler( + Path(uuid_str): Path, +) -> Result { + // Parse UUID + let uuid = Uuid::parse_str(&uuid_str).map_err(|_| FileError::InvalidUuid(uuid_str.clone()))?; + + // Initialize RedisClient + let redis_client = RedisClient::new("redis://127.0.0.1/"); + + // Delete the file + FileInfo::delete(uuid, &redis_client).await?; + + info!("Deleted file with UUID: {}", uuid); + + // Prepare the response JSON + let response = json!({ + "message": "File deleted successfully", + }); + + // Return the response with HTTP 204 No Content + Ok((axum::http::StatusCode::NO_CONTENT, Json(response))) +} diff --git a/src/server.rs b/src/server.rs index db2a01a..c872a84 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,8 @@ use axum::{ - extract::DefaultBodyLimit, routing::{get, post}, Extension, Router + extract::DefaultBodyLimit, routing::{delete, get, post, put}, Extension, Router }; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; -use zettle_db::{rabbitmq::{publisher::RabbitMQProducer, RabbitMQConfig}, routes::{file::upload_handler, ingress::ingress_handler, queue_length::queue_length_handler}}; +use zettle_db::{rabbitmq::{publisher::RabbitMQProducer, RabbitMQConfig}, routes::{file::{delete_file_handler, get_file_handler, update_file_handler, upload_handler}, ingress::ingress_handler, queue_length::queue_length_handler}}; use std::sync::Arc; #[tokio::main(flavor = "multi_thread", worker_threads = 2)] @@ -30,8 +30,11 @@ async fn main() -> Result<(), Box> { .route("/message_count", get(queue_length_handler)) .layer(Extension(producer)) .route("/file", post(upload_handler)) - .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)); - + .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) + .route("/file/:uuid", get(get_file_handler)) + .route("/file/:uuid", put(update_file_handler)) + .route("/file/:uuid", delete(delete_file_handler)); + tracing::info!("Listening on 0.0.0.0:3000"); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +test