From a64072bf2a2a169a03fce44b35a0a697db6c429d Mon Sep 17 00:00:00 2001
From: Pierre Jarriges <pierre.jarriges@tutanota.com>
Date: Wed, 20 Jul 2022 15:57:14 +0200
Subject: [PATCH] wip static files indexation

---
 .gitignore                  |   2 +-
 src/core.rs                 |   1 +
 src/core/static_files.rs    | 212 ++++++++++++++++++++++++++++++++++++
 src/main.rs                 |   7 ++
 src/service/static_files.rs | 120 +++++++++++---------
 5 files changed, 292 insertions(+), 50 deletions(-)
 create mode 100644 src/core.rs
 create mode 100644 src/core/static_files.rs

diff --git a/.gitignore b/.gitignore
index 7bbcb76..79d0212 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,4 @@ public/**/*.js
 public/**/view/*
 public/standard/test_sitemap.xml
 public/standard/dyn_sitemap.xml
-public/uploads
\ No newline at end of file
+public/assets/uploads
\ No newline at end of file
diff --git a/src/core.rs b/src/core.rs
new file mode 100644
index 0000000..13f71bd
--- /dev/null
+++ b/src/core.rs
@@ -0,0 +1 @@
+pub mod static_files;
diff --git a/src/core/static_files.rs b/src/core/static_files.rs
new file mode 100644
index 0000000..84ac310
--- /dev/null
+++ b/src/core/static_files.rs
@@ -0,0 +1,212 @@
+use crate::env::Env;
+use crate::AppState;
+use std::fs::create_dir_all;
+use std::path::Path;
+
+#[derive(Debug, Clone)]
+pub struct StaticFilesIndex(pub Vec<String>);
+
+impl StaticFilesIndex {
+    fn rec_read_dir(root: &Path, files: &mut Vec<String>, strip_from: &Path) {
+        for entry in root.read_dir().unwrap() {
+            if let Ok(entry) = entry {
+                if entry.path().is_dir() {
+                    StaticFilesIndex::rec_read_dir(&entry.path(), files, strip_from);
+                } else {
+                    StaticFilesIndex::_push_path(&entry.path(), files, strip_from);
+                }
+            }
+        }
+    }
+
+    fn _push_path(path: &Path, files: &mut Vec<String>, strip_from: &Path) {
+        let push_path = path.strip_prefix(strip_from).unwrap();
+        files.push(push_path.to_str().unwrap().to_owned());
+    }
+
+    pub fn rebuild(&mut self, env: &Env) {
+        let root = env.public_dir.join("assets");
+        self.0 = Vec::new();
+        StaticFilesIndex::rec_read_dir(&root, &mut self.0, &env.public_dir);
+    }
+
+    pub fn push_path(&mut self, path: &Path, env: &Env) {
+        let strip_from = env.public_dir.join("assets");
+        StaticFilesIndex::_push_path(path, &mut self.0, &strip_from);
+    }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum UploadType {
+    Image,
+    Sound,
+    Video,
+    Doc,
+}
+
+pub struct UploadData {
+    pub up_type: UploadType,
+    pub filename: String,
+}
+
+pub fn create_dir_if_missing(path: std::path::PathBuf) -> std::path::PathBuf {
+    if !path.exists() {
+        create_dir_all(&path).unwrap();
+    }
+
+    path
+}
+
+pub fn get_uploads_dir(app_state: &AppState) -> std::path::PathBuf {
+    create_dir_if_missing(app_state.env.public_dir.join("assets/uploads"))
+}
+
+pub fn dirname_from_type(upload_type: &UploadType) -> String {
+    match upload_type {
+        UploadType::Image => String::from("images"),
+        UploadType::Sound => String::from("sounds"),
+        UploadType::Video => String::from("videos"),
+        UploadType::Doc => String::from("docs"),
+    }
+}
+
+pub fn upload_type_from_file_ext(ext: &String) -> UploadType {
+    match &ext[..] {
+        "webp" | "jpg" | "png" | "jpeg" | "bmp" | "gif" => UploadType::Image,
+        "mp3" | "ogg" | "wav" | "opus" => UploadType::Sound,
+        "mp4" | "webm" | "ogv" => UploadType::Video,
+        _ => UploadType::Doc,
+    }
+}
+
+pub fn file_ext(file_name: &String) -> Result<String, String> {
+    let parts = file_name.split(".").collect::<Vec<&str>>();
+
+    let err_msg = format!("Couldn't get extension from filename : {}", file_name);
+
+    if parts.len() < 2 {
+        return Err(err_msg);
+    }
+
+    match parts.last() {
+        Some(ext) => Ok(ext.to_string()),
+        None => Err(err_msg),
+    }
+}
+
+/*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
+ *@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
+ *  _______   ______    ______   _______   *@@
+ * |__   __@ |  ____@  /  ____@ |__   __@  *@@
+ *    |  @   |  @__    \_ @_       |  @    *@@
+ *    |  @   |   __@     \  @_     |  @    *@@
+ *    |  @   |  @___   ____\  @    |  @    *@@
+ *    |__@   |______@  \______@    |__@    *@@
+ *                                         *@@
+ *@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
+ *@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@*/
+
+#[cfg(test)]
+mod test_static_files {
+    use super::*;
+    use std::{fs::remove_dir_all, path::PathBuf};
+
+    #[test]
+    fn should_create_missing_directory() {
+        let missing_path = PathBuf::from("./some_nested/missing_dir");
+        let created_path = create_dir_if_missing(missing_path);
+        assert!(created_path.exists());
+        remove_dir_all(PathBuf::from("./some_nested")).unwrap();
+    }
+
+    #[test]
+    fn uploads_subdirs_should_be_consistent() {
+        assert_eq!(
+            upload_type_from_file_ext(&"jpg".to_string()),
+            UploadType::Image
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"jpeg".to_string()),
+            UploadType::Image
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"png".to_string()),
+            UploadType::Image
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"bmp".to_string()),
+            UploadType::Image
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"gif".to_string()),
+            UploadType::Image
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"webp".to_string()),
+            UploadType::Image
+        );
+
+        assert_eq!(
+            upload_type_from_file_ext(&"mp3".to_string()),
+            UploadType::Sound
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"ogg".to_string()),
+            UploadType::Sound
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"wav".to_string()),
+            UploadType::Sound
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"opus".to_string()),
+            UploadType::Sound
+        );
+
+        assert_eq!(
+            upload_type_from_file_ext(&"mp4".to_string()),
+            UploadType::Video
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"webm".to_string()),
+            UploadType::Video
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"ogv".to_string()),
+            UploadType::Video
+        );
+
+        assert_eq!(
+            upload_type_from_file_ext(&"any".to_string()),
+            UploadType::Doc
+        );
+        assert_eq!(
+            upload_type_from_file_ext(&"jszaj".to_string()),
+            UploadType::Doc
+        );
+    }
+
+    #[test]
+    fn should_get_filename_extension() {
+        fn valid_ext(ext: Result<String, String>) -> String {
+            assert!(ext.is_ok());
+            ext.unwrap()
+        }
+
+        let filename = String::from("somefilename.png");
+        let ext = valid_ext(file_ext(&filename));
+        assert_eq!(ext, "png");
+
+        let filename = String::from("aïe aïe aïe.wav");
+        let ext = valid_ext(file_ext(&filename));
+        assert_eq!(ext, "wav");
+
+        let filename = String::from("salut Ça va.machin.jpg");
+        let ext = valid_ext(file_ext(&filename));
+        assert_eq!(ext, "jpg");
+
+        let filename = String::from("no extension");
+        let ext = file_ext(&filename);
+        assert!(ext.is_err());
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 99b3a57..6439d51 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 //! # WEB SERVER FOR THE KUADRADO SOFTWARE WEBSITE
 mod app_state;
+mod core;
 mod crypto;
 mod env;
 mod init_admin;
@@ -11,6 +12,7 @@ mod static_view;
 mod tls;
 mod view;
 mod view_resource;
+use crate::core::static_files::StaticFilesIndex;
 use actix_files::Files;
 use actix_web::{
     middleware::{normalize::TrailingSlash, Logger, NormalizePath},
@@ -37,6 +39,10 @@ async fn main() -> std::io::Result<()> {
     let server_port_tls = env_var("SERVER_PORT_TLS").expect("SERVER_PORT_TLS is not defined.");
     let app_state = AppState::with_default_admin_user().await;
 
+    let mut static_files_index = StaticFilesIndex(Vec::new());
+    static_files_index.rebuild(&app_state.env);
+    let static_files_index = Data::new(std::sync::Mutex::new(static_files_index));
+
     HttpServer::new(move || {
         App::new()
             .wrap(Logger::default())
@@ -47,6 +53,7 @@ async fn main() -> std::io::Result<()> {
             )]))
             .wrap(actix_web::middleware::Compress::default())
             .app_data(Data::new(app_state.clone()))
+            .app_data(Data::clone(&static_files_index))
             .app_data(Data::new(AuthenticatedAdminMiddleware::new(
                 "kuadrado-admin-auth",
             )))
diff --git a/src/service/static_files.rs b/src/service/static_files.rs
index a179b2b..b9c133b 100644
--- a/src/service/static_files.rs
+++ b/src/service/static_files.rs
@@ -1,16 +1,65 @@
-use crate::{middleware::AuthenticatedAdminMiddleware, AppState};
+use crate::{core::static_files::*, middleware::AuthenticatedAdminMiddleware, AppState};
 use actix_multipart::Multipart;
 use actix_web::{post, web::Data, HttpRequest, HttpResponse, Responder};
 use futures::StreamExt;
 use std::{
-    fs::{create_dir_all, remove_file, File},
+    fs::{remove_file, File},
     io::Write,
 };
 
-// TODO separate file writing logic from Multipart implementation and implement the unit tests
+fn upload_data_from_multipart_field(field: &actix_multipart::Field) -> Result<UploadData, String> {
+    match field.content_disposition() {
+        Some(content_disposition) => match content_disposition.get_filename() {
+            Some(fname) => match file_ext(&fname.to_string()) {
+                Ok(ext) => Ok(UploadData {
+                    up_type: upload_type_from_file_ext(&ext),
+                    filename: fname.to_owned(),
+                }),
+                Err(msg) => return Err(msg),
+            },
+            None => Err("Couldn't retrieve file extension".to_string()),
+        },
+        None => Err("Missing content disposition".to_string()),
+    }
+}
+
+async fn write_uploaded_file(
+    app_state: &AppState,
+    field: &mut actix_multipart::Field,
+    filename: &String,
+    upload_type: UploadType,
+) -> Result<String, String> {
+    let uploads_dir = get_uploads_dir(app_state);
+    let sub_dir = dirname_from_type(&upload_type);
+    let filepath = create_dir_if_missing(uploads_dir.join(&sub_dir)).join(&filename);
+
+    match File::create(&filepath) {
+        Err(e) => Err(format!("Error creating file {:?}", e)),
+        Ok(mut f) => {
+            // Field in turn is stream of *Bytes* object
+            while let Some(chunk) = field.next().await {
+                match chunk {
+                    Ok(chunk) => {
+                        if f.write_all(&chunk).is_err() {
+                            remove_file(&filepath).unwrap();
+                            return Err("Error writing chunk".to_string());
+                        }
+                    }
+                    Err(e) => {
+                        return Err(format!("Error writing file {} : {:?}", filename, e));
+                    }
+                }
+            }
+
+            Ok(filepath.into_os_string().into_string().unwrap())
+        }
+    }
+}
+
 #[post("/post-files")]
 pub async fn post_files(
     app_state: Data<AppState>,
+    static_files_index: Data<std::sync::Mutex<StaticFilesIndex>>,
     mut payload: Multipart,
     middleware: Data<AuthenticatedAdminMiddleware<'_>>,
     req: HttpRequest,
@@ -19,61 +68,34 @@ pub async fn post_files(
         return HttpResponse::Unauthorized().finish();
     }
 
-    let uploads_dir = app_state.env.public_dir.join("uploads");
-
-    if !uploads_dir.exists() {
-        create_dir_all(&uploads_dir).unwrap();
-    }
-
     let mut uploaded_filepathes = Vec::new();
+    let mut files_index = static_files_index.lock().unwrap();
 
     while let Some(item) = payload.next().await {
         match item {
             Ok(mut field) => {
-                // Field in turn is stream of *Bytes* object
-                // A multipart/form-data stream has to contain `content_disposition`
-                let content_disposition = field
-                    .content_disposition()
-                    .expect("Missing Content Disposition header");
-
-                let filename = content_disposition
-                    .get_filename()
-                    .expect("Missing filename");
-
-                let filepath = uploads_dir.join(&filename);
+                let up_data = upload_data_from_multipart_field(&field);
 
-                let f = File::create(&filepath);
-
-                if f.is_err() {
-                    return HttpResponse::InternalServerError()
-                        .body(format!("Error creating file {:?}", f));
+                if let Err(msg) = up_data {
+                    return HttpResponse::InternalServerError().body(msg);
                 }
 
-                let mut f = f.unwrap();
-                let mut error = None;
-
-                // Field in turn is stream of *Bytes* object
-                'chunks: while let Some(chunk) = field.next().await {
-                    match chunk {
-                        Ok(chunk) => {
-                            if f.write_all(&chunk).is_err() {
-                                error = Some("Error writing chunk".to_string());
-                                break 'chunks;
-                            }
-                        }
-                        Err(e) => {
-                            error = Some(format!("Error writing file {} : {:?}", filename, e));
-                            break 'chunks;
-                        }
+                let up_data = up_data.unwrap();
+
+                match write_uploaded_file(
+                    &app_state,
+                    &mut field,
+                    &up_data.filename,
+                    up_data.up_type,
+                )
+                .await
+                {
+                    Err(msg) => return HttpResponse::InternalServerError().body(msg),
+                    Ok(filepath) => {
+                        files_index.push_path(std::path::Path::new(&filepath), &app_state.env);
+                        uploaded_filepathes.push(filepath);
                     }
                 }
-
-                if let Some(err) = error {
-                    remove_file(&filepath).unwrap();
-                    return HttpResponse::InternalServerError().body(err);
-                }
-
-                uploaded_filepathes.push(String::from(filepath.to_string_lossy()));
             }
             Err(e) => {
                 return HttpResponse::InternalServerError().body(format!("FIELD ERR {:?}", e))
@@ -84,7 +106,7 @@ pub async fn post_files(
     HttpResponse::Ok().json(uploaded_filepathes)
 }
 
-// EXAMPLE FROM ACTIX REPO
+// EXAMPLE FROM ACTIX REPO (using threadpool)
 
 // use futures::TryStreamExt;
 // use std::io::Write;
-- 
GitLab