rustream/routes/
fileio.rs

1use std::fs;
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use actix_web::{HttpRequest, HttpResponse, web};
7use actix_web::http::StatusCode;
8use fernet::Fernet;
9use serde::Deserialize;
10
11use crate::{constant, routes, squire};
12
13/// Struct to represent the payload data with the URL locator and path locator and the new name for the file.
14#[derive(Debug, Deserialize)]
15struct Payload {
16    url_locator: Option<String>,
17    path_locator: Option<String>,
18    new_name: Option<String>
19}
20
21/// Extracts the path the file/directory that has to be modified from the payload received.
22///
23/// # Arguments
24///
25/// * `payload` - Payload received from the UI as JSON body.
26/// * `media_source` - Media source configured for the server.
27///
28/// # Returns
29///
30/// Returns a result object to describe the status of the extraction.
31///
32/// * `Ok(PathBuf)` - If the extraction was successful and the path exists in the server.
33/// * `Err(String)` - If the extraction has failed or if the path doesn't exist in the server.
34fn extract_media_path(payload: &web::Json<Payload>, media_source: &Path) -> Result<PathBuf, String> {
35    let url_locator = payload.url_locator.as_deref();
36    let path_locator = payload.path_locator.as_deref();
37    if let (Some(url_str), Some(path_str)) = (url_locator, path_locator) {
38        // Create a collection since a tuple is a fixed-size collection in rust and doesn't allow iteration
39        for locator in &[url_str, path_str] {
40            if let Some(media_path) = locator.split("stream").nth(1) {
41                // Without stripping the '/' in front of the path, Rust will assume that's a root path
42                // This will overwrite media_source and render the joined path instead of combining the two
43                let path = media_source.join(media_path.strip_prefix('/').unwrap());
44                if path.exists() {
45                    log::debug!("Extracted from '{}'", locator);
46                    return Ok(path);
47                }
48            }
49        }
50        return Err(String::from("Unable to extract path from either of the parameters"));
51    }
52    Err(String::from("Both URL locator and path locator must be provided"))
53}
54
55/// Handles requests for the `/edit` endpoint, to delete/rename media files and directories.
56///
57/// # Arguments
58///
59/// * `request` - A reference to the Actix web `HttpRequest` object.
60/// * `payload` - JSON payload with `url_path` and `true_path` received from the UI.
61/// * `fernet` - Fernet object to encrypt the auth payload that will be set as `session_token` cookie.
62/// * `session` - Session struct that holds the `session_mapping` and `session_tracker` to handle sessions.
63/// * `metadata` - Struct containing metadata of the application.
64/// * `config` - Configuration data for the application.
65/// * `template` - Configuration container for the loaded templates.
66///
67/// # Returns
68///
69/// * `200` - Blank HttpResponse to indicate that the request was successful.
70/// * `400` - HttpResponse with an error message for invalid action or incorrect payload.
71/// * `401` - HttpResponse with an error message for failed authentication.
72/// * `500` - HttpResponse with an error message for failed delete/rename.
73#[post("/edit")]
74pub async fn edit(request: HttpRequest,
75                  payload: web::Json<Payload>,
76                  fernet: web::Data<Arc<Fernet>>,
77                  session: web::Data<Arc<constant::Session>>,
78                  metadata: web::Data<Arc<constant::MetaData>>,
79                  config: web::Data<Arc<squire::settings::Config>>,
80                  template: web::Data<Arc<minijinja::Environment<'static>>>) -> HttpResponse {
81    let auth_response = squire::authenticator::verify_token(&request, &config, &fernet, &session);
82    if !auth_response.ok {
83        return routes::auth::failed_auth(auth_response, &config);
84    }
85    let (_host, _last_accessed) = squire::custom::log_connection(&request, &session);
86    log::debug!("{}", auth_response.detail);
87    let extracted = extract_media_path(&payload, &config.media_source);
88    // todo: styling of the pop up is very basic
89    let media_path: PathBuf = match extracted {
90        Ok(path) => {
91            path
92        },
93        Err(msg) => {
94            return HttpResponse::BadRequest().body(msg);
95        }
96    };
97    if !squire::authenticator::verify_secure_index(&PathBuf::from(&media_path), &auth_response.username) {
98        return squire::custom::error(
99            "RESTRICTED SECTION",
100            template.get_template("error").unwrap(),
101            &metadata.pkg_version,
102            format!("This content is not accessible, as it does not belong to the user profile '{}'", auth_response.username),
103            StatusCode::FORBIDDEN
104        );
105    }
106    if let Some(edit_action) = request.headers().get("edit-action") {
107        let action = edit_action.to_str().unwrap();
108        log::info!("{} requested to {} {:?}", &auth_response.username, action, &media_path);
109        return if action == "delete" {
110            return delete(media_path);
111        } else if action == "rename" {
112            let new_name_str = payload.new_name.as_deref();
113            if let Some(new_name) = new_name_str {
114                return rename(media_path, new_name.trim());
115            } else {
116                HttpResponse::BadRequest().body("New name is missing!")
117            }
118        } else {
119            log::warn!("Unsupported action: {} requested to {} {:?}", &auth_response.username, action, &media_path);
120            HttpResponse::BadRequest().body("Unsupported action!")
121        };
122    }
123    log::warn!("No action received for: {:?}", media_path);
124    HttpResponse::BadRequest().body("No action received!")
125}
126
127/// Checks if the new filename is valid with multiple conditions.
128///
129/// # Arguments
130///
131/// * `old_filepath` - PathBuf object to the file that has to be renamed.
132/// * `new_name` - New name for the file.
133///
134/// ## See Also
135///
136/// - `Condition 1` - Validate if the new filename is the same as old.
137/// - `Condition 2` - Validate if the new filename starts or ends with `.` or `_`
138/// - `Condition 3` - Validate if the new filename and the old has the same file extension.
139/// - `Condition 4` - Validate if the new filename has at least one character, apart from the file extension.
140///
141/// # Returns
142///
143/// Returns a result object to describe the status of the validation.
144///
145/// * `Ok(bool)` - If the new name has passed all the validations.
146/// * `Err(String)` - If the validation has failed.
147fn is_valid_name(old_filepath: &PathBuf, new_name: &str) -> Result<bool, String> {
148    let old_name_str = old_filepath.file_name().unwrap_or_default().to_str().unwrap_or_default();
149    if old_name_str == new_name {
150        return Err(format!("New name cannot be the same as old\n\n'{:?}'=='{new_name}'", old_filepath))
151    }
152    if new_name.starts_with('_') || new_name.ends_with('_') ||
153        new_name.starts_with('.') || new_name.ends_with('.') {
154        return Err(format!("New name cannot start or end with '.' or '_'\n\n'{}'", new_name))
155    }
156    let old_extension = old_filepath.extension().unwrap().to_str().unwrap();
157    let new_extension = new_name.split('.').last().unwrap_or_default();
158    if old_extension != new_extension {
159        return Err(format!("File extension cannot be changed\n\n'{new_extension}' => '{old_extension}'"))
160    }
161    if new_name.len() <= old_extension.len() + 1 {
162        return Err(format!("At least one character is required as filename\n\nReceived {}", new_name.len()))
163    }
164    Ok(true)
165}
166
167/// Renames the file.
168///
169/// # Arguments
170///
171/// - `old_filepath` - PathBuf object to the file that has to be renamed.
172/// - `new_name` - New name for the file.
173///
174/// # Returns
175///
176/// * `200` - Blank HttpResponse to indicate that the request was successful.
177/// * `400` - HttpResponse with an error message for invalid action or incorrect payload.
178/// * `500` - HttpResponse with an error message for failed rename.
179fn rename(media_path: PathBuf, new_name: &str) -> HttpResponse {
180    if new_name.is_empty() {
181        let reason = "New name not received in payload";
182        log::warn!("{}", reason);
183        return HttpResponse::BadRequest().body(reason);
184    }
185    if !media_path.is_file() {
186        let reason = format!("{:?} is an invalid file entry", media_path);
187        return HttpResponse::BadRequest().body(reason);
188    }
189    let validity = is_valid_name(
190        &media_path, new_name
191    );
192    return match validity {
193        Ok(_) => {
194            let new_path = media_path.parent().unwrap().join(new_name).to_string_lossy().to_string();
195            let old_path = media_path.to_string_lossy().to_string();
196            if let Err(error) = fs::rename(old_path, new_path) {
197                let reason = format!("Error renaming file: {}", error);
198                log::error!("{}", reason);
199                HttpResponse::InternalServerError().body(reason)
200            } else {
201                HttpResponse::Ok().finish()
202            }
203        },
204        Err(msg) => {
205            HttpResponse::BadRequest().body(msg)
206        }
207    };
208}
209
210/// Deletes the file.
211///
212/// # Arguments
213///
214/// - `media_path` - PathBuf object to the file that has to be deleted.
215///
216/// # Returns
217///
218/// * `200` - Blank HttpResponse to indicate that the request was successful.
219/// * `400` - HttpResponse with an error message for invalid action or incorrect payload.
220/// * `500` - HttpResponse with an error message for failed delete.
221fn delete(media_path: PathBuf) -> HttpResponse {
222    if media_path.is_file() {
223        if let Err(error) = fs::remove_file(media_path) {
224            let reason = format!("Error deleting file: {}", error);
225            log::error!("{}", reason);
226            HttpResponse::InternalServerError().body(reason)
227        } else {
228            HttpResponse::Ok().finish()
229        }
230    } else if media_path.is_dir() {
231        if let Err(error) = fs::remove_dir_all(media_path) {
232            let reason = format!("Error deleting directory: {}", error);
233            log::error!("{}", reason);
234            HttpResponse::InternalServerError().body(reason)
235        } else {
236            HttpResponse::Ok().finish()
237        }
238    } else {
239        let reason = format!("{:?} was neither a file nor a directory", media_path);
240        log::warn!("{}", reason);
241        HttpResponse::BadRequest().body(reason)
242    }
243}