From b119849be4e63573d28f71406c46673638f5be34 Mon Sep 17 00:00:00 2001 From: jb-alvarado Date: Wed, 22 Jun 2022 18:00:31 +0200 Subject: [PATCH] add file move/copy, add file delete and file upload --- Cargo.lock | 47 ++++++ ffplayout-api/Cargo.toml | 3 + ffplayout-api/src/main.rs | 10 +- ffplayout-api/src/utils/errors.rs | 32 +++- ffplayout-api/src/utils/files.rs | 240 ++++++++++++++++++++++++++++-- ffplayout-api/src/utils/routes.rs | 43 +++++- 6 files changed, 355 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca8d56a2..53720a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,24 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-multipart" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9edfb0e7663d7fe18c8d5b668c9c1bcf79176b1dcc9d4da9592503209a6bfb0" +dependencies = [ + "actix-utils", + "actix-web", + "bytes", + "derive_more", + "futures-core", + "httparse", + "local-waker", + "log", + "mime", + "twoway", +] + [[package]] name = "actix-router" version = "0.5.0" @@ -982,6 +1000,7 @@ dependencies = [ name = "ffplayout-api" version = "0.3.0" dependencies = [ + "actix-multipart", "actix-web", "actix-web-grants", "actix-web-httpauth", @@ -992,6 +1011,7 @@ dependencies = [ "faccess", "ffplayout-lib", "ffprobe", + "futures-util", "jsonwebtoken", "log", "once_cell", @@ -1001,6 +1021,7 @@ dependencies = [ "regex", "relative-path", "reqwest", + "sanitize-filename", "serde", "serde_json", "serde_yaml", @@ -2466,6 +2487,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf18934a12018228c5b55a6dae9df5d0641e3566b3630cb46cc55564068e7c2f" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "schannel" version = "0.1.20" @@ -2996,12 +3027,28 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "twoway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47" +dependencies = [ + "memchr", + "unchecked-index", +] + [[package]] name = "typenum" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "unchecked-index" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" + [[package]] name = "unicase" version = "2.6.0" diff --git a/ffplayout-api/Cargo.toml b/ffplayout-api/Cargo.toml index c4183d13..72fc94f9 100644 --- a/ffplayout-api/Cargo.toml +++ b/ffplayout-api/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" [dependencies] ffplayout-lib = { path = "../lib" } +actix-multipart = "0.4" actix-web = "4" actix-web-grants = "3" actix-web-httpauth = "0.6" @@ -18,6 +19,7 @@ clap = { version = "3.2", features = ["derive"] } derive_more = "0.99" faccess = "0.2" ffprobe = "0.3" +futures-util = { version = "0.3", default-features = false, features = ["std"] } jsonwebtoken = "8" log = "0.4" once_cell = "1.10" @@ -26,6 +28,7 @@ rand_core = { version = "0.6", features = ["std"] } relative-path = "1.6" regex = "1" reqwest = { version = "0.11", features = ["blocking", "json"] } +sanitize-filename = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.8" diff --git a/ffplayout-api/src/main.rs b/ffplayout-api/src/main.rs index fc1ec396..64a5daa5 100644 --- a/ffplayout-api/src/main.rs +++ b/ffplayout-api/src/main.rs @@ -17,8 +17,9 @@ use utils::{ routes::{ add_preset, add_user, del_playlist, file_browser, gen_playlist, get_playlist, get_playout_config, get_presets, get_settings, jump_to_last, jump_to_next, login, - media_current, media_last, media_next, patch_settings, reset_playout, save_playlist, - send_text_message, update_playout_config, update_preset, update_user, + media_current, media_last, media_next, move_rename, patch_settings, remove, reset_playout, + save_file, save_playlist, send_text_message, update_playout_config, update_preset, + update_user, }, run_args, Role, }; @@ -95,7 +96,10 @@ async fn main() -> std::io::Result<()> { .service(save_playlist) .service(gen_playlist) .service(del_playlist) - .service(file_browser), + .service(file_browser) + .service(move_rename) + .service(remove) + .service(save_file), ) }) .bind((addr, port))? diff --git a/ffplayout-api/src/utils/errors.rs b/ffplayout-api/src/utils/errors.rs index 8c31bbe0..2bb20ab9 100644 --- a/ffplayout-api/src/utils/errors.rs +++ b/ffplayout-api/src/utils/errors.rs @@ -1,4 +1,4 @@ -use actix_web::{error::ResponseError, HttpResponse}; +use actix_web::{error::ResponseError, Error, HttpResponse}; use derive_more::Display; #[derive(Debug, Display)] @@ -29,3 +29,33 @@ impl ResponseError for ServiceError { } } } + +impl From for ServiceError { + fn from(err: String) -> ServiceError { + ServiceError::BadRequest(err) + } +} + +impl From for ServiceError { + fn from(err: Error) -> ServiceError { + ServiceError::BadRequest(err.to_string()) + } +} + +impl From for ServiceError { + fn from(err: actix_multipart::MultipartError) -> ServiceError { + ServiceError::BadRequest(err.to_string()) + } +} + +impl From for ServiceError { + fn from(err: std::io::Error) -> ServiceError { + ServiceError::BadRequest(err.to_string()) + } +} + +impl From for ServiceError { + fn from(err: actix_web::error::BlockingError) -> ServiceError { + ServiceError::BadRequest(err.to_string()) + } +} diff --git a/ffplayout-api/src/utils/files.rs b/ffplayout-api/src/utils/files.rs index 4343d4ef..1e04cd2a 100644 --- a/ffplayout-api/src/utils/files.rs +++ b/ffplayout-api/src/utils/files.rs @@ -1,6 +1,15 @@ +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use actix_multipart::Multipart; +use actix_web::{web, HttpResponse}; +use futures_util::TryStreamExt as _; +use rand::{distributions::Alphanumeric, Rng}; use relative_path::RelativePath; use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf}; use simplelog::*; @@ -9,28 +18,32 @@ use ffplayout_lib::utils::file_extension; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct PathObject { - root: String, - #[serde(skip_deserializing)] - folders: Vec, - #[serde(skip_deserializing)] - files: Vec, + pub source: String, + folders: Option>, + files: Option>, } impl PathObject { - fn new(root: String) -> Self { + fn new(source: String) -> Self { Self { - root, - folders: vec![], - files: vec![], + source, + folders: Some(vec![]), + files: Some(vec![]), } } } +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct MoveObject { + source: String, + target: String, +} + pub async fn browser(id: i64, path_obj: &PathObject) -> Result { let (config, _) = playout_config(&id).await?; let path = PathBuf::from(config.storage.path); let extensions = config.storage.extensions; - let path_component = RelativePath::new(&path_obj.root) + let path_component = RelativePath::new(&path_obj.source) .normalize() .to_string() .replace("../", ""); @@ -57,11 +70,15 @@ pub async fn browser(id: i64, path_obj: &PathObject) -> Result Result Result { +// match fs::copy(&source, &target) { +// Ok(_) => { +// if let Err(e) = fs::remove_file(source) { +// error!("{e}"); +// return Err(ServiceError::BadRequest( +// "Removing File not possible!".into(), +// )); +// }; + +// return Ok(PathObject::new(target.display().to_string())); +// } +// Err(e) => { +// error!("{e}"); +// Err(ServiceError::BadRequest("Error in file copy!".into())) +// } +// } +// } + +fn rename(source: &PathBuf, target: &PathBuf) -> Result { + match fs::rename(&source, &target) { + Ok(_) => Ok(MoveObject { + source: source.display().to_string(), + target: target.display().to_string(), + }), + Err(e) => { + error!("{e}"); + Err(ServiceError::BadRequest("Rename failed!".into())) + } + } +} + +pub async fn rename_file(id: i64, move_object: &MoveObject) -> Result { + let (config, _) = playout_config(&id).await?; + let path = PathBuf::from(&config.storage.path); + let source = RelativePath::new(&move_object.source) + .normalize() + .to_string() + .replace("../", ""); + let target = RelativePath::new(&move_object.target) + .normalize() + .to_string() + .replace("../", ""); + + let mut source_path = PathBuf::from(source.clone()); + let mut target_path = PathBuf::from(target.clone()); + + let relativ_path = RelativePath::new(&config.storage.path) + .normalize() + .to_string(); + + if !source_path.starts_with(&relativ_path) { + source_path = path.join(source); + } else { + source_path = path.join(source_path.strip_prefix(&relativ_path).unwrap()); + } + + if !target_path.starts_with(&relativ_path) { + target_path = path.join(target); + } else { + target_path = path.join(target_path.strip_prefix(relativ_path).unwrap()); + } + + if !source_path.exists() { + return Err(ServiceError::BadRequest("Source file not exist!".into())); + } + + if (source_path.is_dir() || source_path.is_file()) && source_path.parent() == Some(&target_path) + { + return rename(&source_path, &target_path); + } + + if target_path.is_dir() { + target_path = target_path.join(source_path.file_name().unwrap()); + } + + if target_path.is_file() { + return Err(ServiceError::BadRequest( + "Target file already exists!".into(), + )); + } + + if source_path.is_file() && target_path.parent().is_some() { + return rename(&source_path, &target_path); + } + + Err(ServiceError::InternalServerError) +} + +pub async fn remove_file_or_folder(id: i64, source_path: &str) -> Result<(), ServiceError> { + let (config, _) = playout_config(&id).await?; + let source = PathBuf::from(source_path); + + let test_source = RelativePath::new(&source_path) + .normalize() + .to_string() + .replace("../", ""); + + let test_path = RelativePath::new(&config.storage.path) + .normalize() + .to_string(); + + if !test_source.starts_with(&test_path) { + return Err(ServiceError::BadRequest( + "Source file is not in storage!".into(), + )); + } + + if !source.exists() { + return Err(ServiceError::BadRequest("Source does not exists!".into())); + } + + if source.is_dir() { + match fs::remove_dir(source) { + Ok(_) => return Ok(()), + Err(e) => { + error!("{e}"); + return Err(ServiceError::BadRequest( + "Delete folder failed! (Folder must be empty)".into(), + )); + } + }; + } + + if source.is_file() { + match fs::remove_file(source) { + Ok(_) => return Ok(()), + Err(e) => { + error!("{e}"); + return Err(ServiceError::BadRequest("Delete file failed!".into())); + } + }; + } + + Err(ServiceError::InternalServerError) +} + +async fn valid_path(id: i64, path: &str) -> Result<(), ServiceError> { + let (config, _) = playout_config(&id).await?; + + let test_target = RelativePath::new(&path) + .normalize() + .to_string() + .replace("../", ""); + + let test_path = RelativePath::new(&config.storage.path) + .normalize() + .to_string(); + + if !test_target.starts_with(&test_path) { + return Err(ServiceError::BadRequest( + "Target folder is not in storage!".into(), + )); + } + + if !Path::new(path).is_dir() { + return Err(ServiceError::BadRequest("Target folder not exists!".into())); + } + + Ok(()) +} + +pub async fn upload(id: i64, mut payload: Multipart) -> Result { + while let Some(mut field) = payload.try_next().await? { + let content_disposition = field.content_disposition(); + println!("{content_disposition}"); + let rand_string: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(20) + .map(char::from) + .collect(); + let path_name = content_disposition.get_name().unwrap_or(&rand_string); + let filename = content_disposition + .get_filename() + .map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize); + + if let Err(e) = valid_path(id, path_name).await { + return Err(e); + } + + let filepath = PathBuf::from(path_name).join(filename); + + if filepath.is_file() { + return Err(ServiceError::BadRequest("Target already exists!".into())); + } + + // File::create is blocking operation, use threadpool + let mut f = web::block(|| std::fs::File::create(filepath)).await??; + + while let Some(chunk) = field.try_next().await? { + f = web::block(move || f.write_all(&chunk).map(|_| f)).await??; + } + } + + Ok(HttpResponse::Ok().into()) +} diff --git a/ffplayout-api/src/utils/routes.rs b/ffplayout-api/src/utils/routes.rs index 3c98ba09..47088d21 100644 --- a/ffplayout-api/src/utils/routes.rs +++ b/ffplayout-api/src/utils/routes.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use actix_web::{delete, get, http::StatusCode, patch, post, put, web, Responder}; +use actix_multipart::Multipart; +use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder}; use actix_web_grants::{permissions::AuthDetails, proc_macro::has_any_role}; use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, SaltString}, @@ -13,7 +14,7 @@ use crate::utils::{ auth::{create_jwt, Claims}, control::{control_state, media_info, send_message}, errors::ServiceError, - files::{browser, PathObject}, + files::{browser, remove_file_or_folder, rename_file, upload, MoveObject, PathObject}, handles::{ db_add_preset, db_add_user, db_get_presets, db_get_settings, db_login, db_role, db_update_preset, db_update_settings, db_update_user, @@ -415,7 +416,7 @@ pub async fn del_playlist( /// /// ---------------------------------------------------------------------------- -/// curl -X get http://localhost:8080/api/file/1/browse +/// curl -X GET http://localhost:8080/api/file/1/browse/ /// --header 'Content-Type: application/json' --header 'Authorization: ' #[post("/file/{id}/browse/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] @@ -428,3 +429,39 @@ pub async fn file_browser( Err(e) => Err(e), } } + +/// curl -X POST http://localhost:8080/api/file/1/move/ +/// --header 'Content-Type: application/json' --header 'Authorization: ' +/// -d '{"source": "", "target": ""}' +#[post("/file/{id}/move/")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +pub async fn move_rename( + id: web::Path, + data: web::Json, +) -> Result { + match rename_file(*id, &data.into_inner()).await { + Ok(obj) => Ok(web::Json(obj)), + Err(e) => Err(e), + } +} + +/// curl -X DELETE http://localhost:8080/api/file/1/remove/ +/// --header 'Content-Type: application/json' --header 'Authorization: ' +/// -d '{"source": "", "target": ""}' +#[delete("/file/{id}/remove/")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +pub async fn remove( + id: web::Path, + data: web::Json, +) -> Result { + match remove_file_or_folder(*id, &data.into_inner().source).await { + Ok(obj) => Ok(web::Json(obj)), + Err(e) => Err(e), + } +} + +#[post("/file/{id}/upload/")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +async fn save_file(id: web::Path, payload: Multipart) -> Result { + upload(*id, payload).await +}