From ff014505e3add1d88a650840624d9a8b4318b907 Mon Sep 17 00:00:00 2001 From: Bowarc Date: Thu, 8 Aug 2024 02:33:09 +0200 Subject: [PATCH] Reworked the upload route to be PUT (gh-8) --- back/src/cache.rs | 24 ++++-- back/src/main.rs | 71 ++++++++------- back/src/response.rs | 86 +++++++++++++----- back/src/routes.rs | 55 ++++++------ back/src/routes/download.rs | 162 +++++++++++++++++++++++++++++++++- back/src/routes/upload.rs | 168 +++++++++++++++++++++++++++--------- 6 files changed, 431 insertions(+), 135 deletions(-) diff --git a/back/src/cache.rs b/back/src/cache.rs index 881bb05..a5ee436 100644 --- a/back/src/cache.rs +++ b/back/src/cache.rs @@ -7,8 +7,10 @@ use { shared::data::{CacheEntry, Metadata}, std::sync::{atomic::Ordering, Arc}, }; - +#[cfg(not(test))] const CACHE_DIRECTORY: &'static str = "./cache"; +#[cfg(test)] +const CACHE_DIRECTORY: &'static str = "../cache"; // For some reason, tests launch path is ./back const COMPRESSION_LEVEL: i32 = 5; // 1..=11 #[derive(Default)] @@ -21,7 +23,7 @@ impl Cache { pub fn new() -> Option { use std::str::FromStr as _; let files = std::fs::read_dir(CACHE_DIRECTORY) - .map_err(|e| format!("Could not open cache dir due to: {e}")) + .map_err(|e| error!("Could not open cache dir due to: {e}")) .ok()?; // The default one is bad @@ -34,7 +36,7 @@ impl Cache { let metadata = entry .metadata() .map_err(|e| { - format!( + error!( "Could not read metadata from cache file '{p}' due to: {e}", p = display_path(entry.path()) ) @@ -71,10 +73,10 @@ impl Cache { let file_content: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(path.clone()) - .map_err(|e| format!("Could not open cache file '{id}' due to: {e}")) + .map_err(|e| error!("Could not open cache file '{id}' due to: {e}")) .ok()?, ) - .map_err(|e| format!("Could not deserialize cache file '{id}' due to: {e}")) + .map_err(|e| error!("Could not deserialize cache file '{id}' due to: {e}")) .ok()?; let Some(username) = file_content @@ -158,10 +160,20 @@ impl Cache { }) } + pub async fn get_meta(&self, id: uuid::Uuid) -> Result { + Ok(self + .data + .iter() + .find(|e| e.id == id) + .ok_or(CacheError::NotFound)? + .metadata + .clone()) + } + pub async fn load(&self, id: uuid::Uuid) -> Result<(Metadata, Vec), CacheError> { use tokio::io::AsyncReadExt; - // Load and decompress the given cache entry + let entry = self .data .iter() diff --git a/back/src/main.rs b/back/src/main.rs index 1c4664f..fd77559 100644 --- a/back/src/main.rs +++ b/back/src/main.rs @@ -5,6 +5,9 @@ extern crate thiserror; #[macro_use(trace, debug, info, warn, error)] extern crate log; +#[macro_use(lazy_static)] +extern crate lazy_static; + mod cache; mod catchers; mod error; @@ -13,25 +16,15 @@ mod routes; static mut JSON_REQ_LIMIT: rocket::data::ByteUnit = rocket::data::ByteUnit::Byte(0); -#[rocket::main] -async fn main() { - let logcfg = logger::LoggerConfig::new() - .set_level(log::LevelFilter::Trace) - .add_filter("rocket", log::LevelFilter::Warn); - logger::init(logcfg, Some("log/server.log")); - - // Small print to show the start of the program log in the file - trace!( - "\n╭{line}╮\n│{message:^30}│\n╰{line}╯", - line = "─".repeat(30), - message = "Program start" - ); - - let cache = - rocket::tokio::sync::RwLock::new(cache::Cache::new().expect("Could not load cache")); +// Needed for tests +pub async fn build_rocket() -> rocket::Rocket { + let Some(cache) = cache::Cache::new() else { + error!("Failled to load cache"); + std::process::exit(1) + }; let rocket = rocket::build() - .manage(cache) + .manage(rocket::tokio::sync::RwLock::new(cache)) .register("/", rocket::catchers![catchers::root_403]) .register( "/upload", @@ -44,25 +37,20 @@ async fn main() { routes::front_js, routes::front_bg_wasm, routes::index_html, - routes::static_resource, routes::static_css, routes::static_lib_live, routes::static_lib_zoom, - routes::favicon_ico, - routes::api_upload, - routes::api_upload2, - + // routes::api_upload_put, routes::api_download, + routes::api_download_get, + routes::api_download_get_proxy, + routes::api_download_head, ], - ) - .ignite() - .await - .unwrap(); + ).ignite().await.unwrap(); - display_config(rocket.config(), rocket.routes(), rocket.catchers()); // Safety: // This will only be writen once and at the reads are not yet loaded because the sever is not yet launched @@ -74,6 +62,27 @@ async fn main() { .expect("Failled to read the normal and default config") } + rocket +} + +#[rocket::main] +async fn main() { + let logcfg = logger::LoggerConfig::new() + .set_level(log::LevelFilter::Trace) + .add_filter("rocket", log::LevelFilter::Warn); + logger::init(logcfg, Some("log/server.log")); + + // Small print to show the start of the program log in the file + trace!( + "\n╭{line}╮\n│{message:^30}│\n╰{line}╯", + line = "─".repeat(30), + message = "Program start" + ); + + let rocket = build_rocket().await; + + display_config(rocket.config(), rocket.routes(), rocket.catchers()); + rocket.launch().await.unwrap(); } @@ -115,10 +124,10 @@ fn display_config<'a>( let name = route .name .as_ref() - .map(|name| name.as_ref()) + .map(std::borrow::Cow::as_ref) .unwrap_or("[ERROR] Undefined"); let method = route.method.as_str(); - format!("{method:<7} {uri:<15} {name}") + format!("{method:<5} {uri:<20} {name}") }) .collect::>(); @@ -128,14 +137,14 @@ fn display_config<'a>( let name = catcher .name .as_ref() - .map(|name| name.as_ref()) + .map(std::borrow::Cow::as_ref) .unwrap_or("[ERROR] Undefined"); let code = catcher .code .map(|code| code.to_string()) .unwrap_or("[ERROR] Undefined".to_string()); - format!("{code:<7} {base:<15} {name}") + format!("{code:<5} {base:<20} {name}") }) .collect::>(); diff --git a/back/src/response.rs b/back/src/response.rs index 23749aa..d43eeaf 100644 --- a/back/src/response.rs +++ b/back/src/response.rs @@ -1,5 +1,5 @@ use rocket::{ - http::Status, + http::{ContentType, Status}, serde::json::serde_json::{json, Value as JsonValue}, }; @@ -62,20 +62,6 @@ impl Default for JsonApiResponseBuilder { headers: { let mut h = std::collections::HashMap::new(); h.insert("Content-Type".to_string(), "application/json".to_string()); - - // Unstable be carefull - h.insert( - "Access-Control-Allow-Origin".to_string(), - "http://localhost:3000".to_string(), - ); - h.insert( - "Access-Control-Allow-Method".to_string(), - "POST,GET,OPTIONS".to_string(), - ); - h.insert( - "Access-Control-Allow-Headers".to_string(), - "X-PINGOTHER, Content-Type".to_string(), - ); h }, }, @@ -84,17 +70,71 @@ impl Default for JsonApiResponseBuilder { } pub struct Response { - pub status: Status, - pub content: Vec, - pub content_type: rocket::http::ContentType, // C TYPE badeu :D + status: Status, + headers: std::collections::HashMap, + content: Vec, + content_type: rocket::http::ContentType, } impl<'r> rocket::response::Responder<'r, 'static> for Response { fn respond_to(self, _: &'r rocket::Request<'_>) -> rocket::response::Result<'static> { - rocket::Response::build() - .header(self.content_type) - .status(self.status) - .sized_body(self.content.len(), std::io::Cursor::new(self.content)) - .ok() + + let mut resp = rocket::response::Builder::new(rocket::Response::default()); + + resp.status(self.status); + + for (name, value) in self.headers { + resp.raw_header(name, value); + } + + resp.sized_body(self.content.len(), std::io::Cursor::new(self.content)); + + resp.ok() + } } + +pub struct ResponseBuilder { + inner: Response, +} + +impl ResponseBuilder { + pub fn with_content(mut self, value: impl Into> ) -> Self { + self.inner.content = value.into(); + self + } + + pub fn with_content_type(mut self, ctype /*C TYPE badeu :D*/: ContentType ) -> Self { + self.inner.content_type = ctype; + self + } + + pub fn with_status(mut self, status: Status) -> Self { + self.inner.status = status; + self + } + + pub fn with_header(mut self, name: &str, value: &str) -> Self { + self.inner + .headers + .insert(name.to_string(), value.to_string()); + self + } + + pub fn build(self) -> Response { + self.inner + } +} + +impl Default for ResponseBuilder { + fn default() -> Self { + ResponseBuilder { + inner: Response { + status: Status::Ok, + headers: std::collections::HashMap::new(), + content: Vec::new(), + content_type: ContentType::Any, + }, + } + } +} \ No newline at end of file diff --git a/back/src/routes.rs b/back/src/routes.rs index 5eb9aa0..499a79f 100644 --- a/back/src/routes.rs +++ b/back/src/routes.rs @@ -1,3 +1,5 @@ +use crate::response::ResponseBuilder; + use { crate::response::Response, rocket::http::{ContentType, Status}, @@ -68,11 +70,9 @@ pub async fn static_resource(file: &str, remote_addr: SocketAddr) -> Response { ]; if !ALLOWED_FILES.contains(&file) { - return Response { - status: Status::NotFound, - content: Vec::new(), - content_type: ContentType::Any, - }; + return ResponseBuilder::default() + .with_status(Status::NotFound) + .build(); } serve_static("/resources", file, remote_addr).await @@ -90,11 +90,9 @@ pub async fn static_css(file: &str, remote_addr: SocketAddr) -> Response { ]; if !ALLOWED_FILES.contains(&file) { - return Response { - status: Status::NotFound, - content: Vec::new(), - content_type: ContentType::Any, - }; + return ResponseBuilder::default() + .with_status(Status::NotFound) + .build(); } serve_static("/css", file, remote_addr).await @@ -105,11 +103,9 @@ pub async fn static_lib_live(file: &str, remote_addr: SocketAddr) -> Response { const ALLOWED_FILES: &[&'static str] = &["live.js"]; if !ALLOWED_FILES.contains(&file) { - return Response { - status: Status::NotFound, - content: Vec::new(), - content_type: ContentType::Any, - }; + return ResponseBuilder::default() + .with_status(Status::NotFound) + .build(); } serve_static("/lib/live", file, remote_addr).await @@ -120,17 +116,16 @@ pub async fn static_lib_zoom(file: &str, remote_addr: SocketAddr) -> Response { const ALLOWED_FILES: &[&'static str] = &["zoom.js", "zoom.css"]; if !ALLOWED_FILES.contains(&file) { - return Response { - status: Status::NotFound, - content: Vec::new(), - content_type: ContentType::Any, - }; + return ResponseBuilder::default() + .with_status(Status::NotFound) + .build(); } serve_static("/lib/zoom", file, remote_addr).await } pub async fn serve_static(path: &str, file: &str, remote_addr: SocketAddr) -> Response { + #[inline] fn ext(file_name: &str) -> Option<&str> { if !file_name.contains(".") { return None; @@ -153,7 +148,6 @@ pub async fn serve_static(path: &str, file: &str, remote_addr: SocketAddr) -> Re static_file_response(&format!("{path}/{file}"), content_type, remote_addr).await } - async fn static_file_response( path: &str, content_type: ContentType, @@ -174,16 +168,15 @@ async fn static_file_response( } match read_static(path, remote_addr).await { - Some(bytes) => Response { - status: Status::Ok, - content: bytes, - content_type: content_type, - }, - None => Response { - status: Status::NotFound, - content: Vec::new(), - content_type: ContentType::Any, - }, + Some(bytes) => ResponseBuilder::default() + .with_status(Status::Ok) + .with_content(bytes) + .with_content_type(content_type).build(), + None => { + return ResponseBuilder::default() + .with_status(Status::NotFound) + .build() + } } } diff --git a/back/src/routes/download.rs b/back/src/routes/download.rs index c94614c..216d967 100644 --- a/back/src/routes/download.rs +++ b/back/src/routes/download.rs @@ -1,9 +1,19 @@ +use rocket::{http::ContentType, response::Redirect, tokio::io::AsyncReadExt, uri}; +use uuid::Uuid; + +use crate::response::{Response, ResponseBuilder}; + use { crate::response::{JsonApiResponse, JsonApiResponseBuilder}, rocket::{http::Status, serde::json::serde_json::json}, std::str::FromStr, }; +lazy_static! { + static ref EXTENSION_VALIDATION_REGEX: regex::Regex = + regex::Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$").unwrap(); +} + #[rocket::get("/api/download/")] pub async fn api_download( id: &str, @@ -12,9 +22,7 @@ pub async fn api_download( debug!("Download request of: {id}"); // Only contains numbers, lowercase letters or dashes - if !id - .chars() - .all(|c| c.is_digit(10) || c == '-' || c.is_ascii_lowercase()){ + if !EXTENSION_VALIDATION_REGEX.is_match(&id) { error!("Given id doesn't match expected character range"); return JsonApiResponseBuilder::default() .with_status(Status::BadRequest) @@ -60,3 +68,151 @@ pub async fn api_download( .with_status(Status::Ok) .build() } + +#[rocket::get("/")] +pub async fn api_download_get_proxy( + id: &str, + cache: &rocket::State>, +) -> rocket::response::Redirect { + info!("Proxy request of {id}"); + + let uuid = Uuid::from_str(id).unwrap(); + + let meta = cache.write().await.get_meta(uuid).await.unwrap(); + + let filename = format!("{}.{}", meta.file_name, meta.file_ext); + info!("Redirecting to {filename}"); + + Redirect::to(uri!(api_download_get(id, filename))) +} + +#[rocket::get("//")] +pub async fn api_download_get( + id: &str, + filename: &str, + cache: &rocket::State>, +) -> Response { + info!("Request of {id} w/ filename: {filename}"); + + let uuid = Uuid::from_str(id).unwrap(); + + let (meta, data) = cache.write().await.load(uuid).await.unwrap(); + + if &format!("{}.{}", meta.file_name, meta.file_ext) != filename { + return ResponseBuilder::default() + .with_status(Status::BadRequest) + .with_content(format!( + "The given filename is not correct, did you meant {}.{}?", + meta.file_name, meta.file_ext + )) + .with_content_type(ContentType::Plain) + .build(); + } + + ResponseBuilder::default() + .with_status(Status::Ok) + .with_content(data) + .with_content_type(ContentType::Bytes).build() +} +#[rocket::head("/")] +pub async fn api_download_head( + id: &str, + cache: &rocket::State>, +) -> String { + info!("Request of HEAD {id}"); + let uuid = Uuid::from_str(id).unwrap(); + + let meta = cache.write().await.get_meta(uuid).await.unwrap(); + + format!("{}.{}", meta.file_name, meta.file_ext) +} + +#[cfg(test)] +mod tests { + use { + crate::build_rocket, + rocket::{http::Status, local::asynchronous::Client}, + std::str::FromStr, + }; + + #[rocket::async_test] + async fn test_download_proxy_GET() { + let base_filename = "test.file"; + + let uuid = { + // Setup + let client = Client::tracked(build_rocket().await) + .await + .expect("valid rocket instance"); + + let response = client + .put(format!("/{base_filename}")) + .header(rocket::http::ContentType::Plain) + .body("This is a co") + .dispatch() + .await; + + #[allow(deprecated)] // stfu ik + std::thread::sleep_ms(500); + + assert_eq!(response.status(), Status::Created); + let suuid = response + .into_string() + .await + .unwrap() + .replace("Success: ", ""); + let uuid = uuid::Uuid::from_str(&suuid).unwrap(); + uuid + }; + + let client = Client::tracked(build_rocket().await) + .await + .expect("valid rocket instance"); + + let response = client + .get(format!("/{uuid}", uuid = uuid.hyphenated())) + .dispatch() + .await; + + assert_eq!(response.status(), Status::SeeOther); // Redirect + assert_eq!( + response.headers().get_one("location").unwrap(), + format!("/{uuid}/{base_filename}") + ); + } + + #[rocket::async_test] + async fn test_download_GET() { + let logcfg = logger::LoggerConfig::new() + .set_level(log::LevelFilter::Trace) + .add_filter("rocket", log::LevelFilter::Warn); + logger::init(logcfg, None); + + let base_filename = "test.file"; + + let uuid = { + // Setup + let client = Client::tracked(build_rocket().await) + .await + .expect("valid rocket instance"); + let response = client + .put(format!("/{base_filename}")) + .header(rocket::http::ContentType::Plain) + .body("This is a co") + .dispatch() + .await; + + #[allow(deprecated)] // stfu ik + std::thread::sleep_ms(500); + + assert_eq!(response.status(), Status::Created); + let suuid = response + .into_string() + .await + .unwrap() + .replace("Success: ", ""); + let uuid = uuid::Uuid::from_str(&suuid).unwrap(); + uuid + }; + } +} diff --git a/back/src/routes/upload.rs b/back/src/routes/upload.rs index b55e737..7fe8107 100644 --- a/back/src/routes/upload.rs +++ b/back/src/routes/upload.rs @@ -1,53 +1,86 @@ +use rocket::http::ContentType; + +use crate::response::{Response, ResponseBuilder}; + use { crate::response::{JsonApiResponse, JsonApiResponseBuilder}, rocket::{http::Status, serde::json::serde_json::json}, }; -#[rocket::post("/api/upload", format = "application/json", data = "")] +lazy_static! { + static ref EXTENSION_VALIDATION_REGEX: regex::Regex = + regex::Regex::new(r"^[A-Za-z0-9_.]{1,100}$").unwrap(); +} + +#[rocket::put("/", data = "")] pub async fn api_upload( - data: rocket::serde::json::Json, + filename: &str, + raw_data: rocket::data::Data<'_>, cache: &rocket::State>, -) -> JsonApiResponse { - // Setup - let start_timer = std::time::Instant::now(); - let id = uuid::Uuid::new_v4(); - let metadata = data.metadata.clone(); - let file_data = &data.file; - let wait_store = true; // Probably better to make this an endpoint like /api/upload/ and /api/upload/awaited/ +) -> Response { + use {std::time::Instant, uuid::Uuid}; + let start_timer = Instant::now(); + + let id = Uuid::new_v4(); + let wait_store = true; // Probably betterto make this an endpoint like /api/upload/ and /api/upload/awaited/; // Validation of user input - if !regex::Regex::new(r"^[A-Za-z0-9]*$") - .unwrap() // Should not fail - .is_match(&metadata.file_ext) + if !EXTENSION_VALIDATION_REGEX.is_match(&filename) { + return ResponseBuilder::default() + .with_status(Status::BadRequest) + .with_content("The specified filename should only contain alphanumeric characters, underscores, dots and shouldn't be longer than 100 characters") + .with_content_type(ContentType::Text) + .build(); + } + + let capped_data = match raw_data + .open(unsafe { crate::JSON_REQ_LIMIT }) + .into_bytes() + .await { - return JsonApiResponseBuilder::default() - .with_json(json!({"result": "denied", "message": "The specified extension should only contain alphanumeric characters"})) - .with_status(Status::BadRequest).build(); + Ok(data) => data, + Err(e) => { + error!("[{id}] Could not parse given data: {e}"); + + return ResponseBuilder::default() + .with_status(Status::BadRequest) + .with_content("The given body content could not be parsed") + .with_content_type(ContentType::Text) + .build(); + } + }; + + if !capped_data.is_complete() { + error!("Data too large"); + return ResponseBuilder::default() + .with_status(Status::PayloadTooLarge) + .with_content("The given data is too large") + .with_content_type(ContentType::Text) + .build(); } + let data = capped_data.to_vec(); + debug!( - "Received new upload request on /json\nUsing id: {id}\nUsername: {}\nFile name: {}\nFile ext: {}\nFile size: {}", - metadata.username, - metadata.file_name, - metadata.file_ext, - rocket::data::ByteUnit::Byte(file_data.len() as u64) + "Received new upload request on /json\nUsing id: {id}\nUsername: {}\nFile name: {}\nFile size: {}", + "NO_USER", + filename, + data.len() ); - // Decode user input | Decoding makes the compression 'faster' koz it has less data to compress - // let file_content = file_data.clone().into_bytes(); - let Ok(file_content) = rbase64::decode(file_data) else { - error!("[{id}] Could not decode request"); - return JsonApiResponseBuilder::default() - .with_json( - json!({"result": "failled", "message": "Could not understand the given data."}), - ) - .with_status(Status::BadRequest) - .build(); - }; + // No need to decode user input as it's not b64 encoded anymore let mut cache_handle = cache.write().await; - let exec = cache_handle.store(id, metadata, file_content); + let exec = cache_handle.store( + id, + shared::data::Metadata { + username: "NO_USER".to_string(), + file_name: get_file_name(filename).unwrap_or_default(), + file_ext: get_file_extension(filename).unwrap_or_default(), + }, + data, + ); // Release the lock to be able to wait the end of the 'exec' without denying other calls drop(cache_handle); @@ -61,18 +94,18 @@ pub async fn api_upload( } Ok(Err(e)) => { error!("[{id}] An error occured while storing the given data: {e}"); - return JsonApiResponseBuilder::default() - .with_json( - json!({"result": "failled", "message": "An error occured while caching the data"}), - ) + return ResponseBuilder::default() .with_status(Status::InternalServerError) + .with_content("An error occured while caching the data") + .with_content_type(ContentType::Text) .build(); } Err(join_error) => { error!("[{id}] Something went really bad while waiting for worker task to end: {join_error}"); - return JsonApiResponseBuilder::default() - .with_json(json!({"result": "failled", "message": "Worker failled"})) + return ResponseBuilder::default() .with_status(Status::InternalServerError) + .with_content("Worker failled") + .with_content_type(ContentType::Text) .build(); } } @@ -82,9 +115,62 @@ pub async fn api_upload( "[{id}] Responded in {}", time::format(start_timer.elapsed(), 2) ); - - JsonApiResponseBuilder::default() - .with_json(json!({"result": "created", "id": id.hyphenated().to_string()})) + ResponseBuilder::default() .with_status(Status::Created) + .with_content(id.hyphenated().to_string()) + .with_content_type(ContentType::Text) .build() } + +fn get_file_name(name: &str) -> Option { + if !name.contains(".") { + return None; + } + + let dot_index = name.rfind(".").unwrap(); + + Some(String::from(&name[0..dot_index])) +} + +fn get_file_extension(name: &str) -> Option { + if !name.contains(".") { + return None; + } + + let dot_index = name.rfind(".").unwrap(); + + Some(String::from(&name[(dot_index + 1)..name.len()])) +} + +#[cfg(test)] +mod tests { + use { + crate::build_rocket, + rocket::{http::Status, local::asynchronous::Client}, + std::str::FromStr, + }; + + #[rocket::async_test] + async fn test_upload_PUT() { + let client = Client::tracked(build_rocket().await) + .await + .expect("valid rocket instance"); + let response = client + .put("/test.file") + .header(rocket::http::ContentType::Plain) + .body("This is normal file content") + .dispatch() + .await; + + #[allow(deprecated)] // stfu ik + std::thread::sleep_ms(500); + + assert_eq!(response.status(), Status::Created); + let suuid = response + .into_string() + .await + .unwrap() + .replace("Success: ", ""); + let _uuid = uuid::Uuid::from_str(&suuid).unwrap(); + } +}