From 27e3be5a2d3de71dc68edbe4e9022bcf08f95753 Mon Sep 17 00:00:00 2001 From: Andy Baird Date: Sun, 3 Sep 2023 10:47:52 -0500 Subject: [PATCH] feat(server): add delete endpoint (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Delete endpoint implementation with delete_tokens configuration * style(server): remove empty line, add dot at end of doc comment * test(fixtures): add fixture test for file delete * refactor(config): add delete_tokens to config.toml * docs(readme): add info on how to delete file from server * Update README.md Co-authored-by: Orhun Parmaksız * Update README.md Co-authored-by: Orhun Parmaksız * refactor(server): use log::error! and return 500 Co-authored-by: Helmut K. C. Tessarek * test(server): add test_delete_file_without_token_in_config * refactor(server): use one function to retrieve auth/delete tokens Co-authored-by: Helmut K. C. Tessarek * test(config): add test_get_tokens * test(config): improve test_get_tokens * feat(config): disallow empty tokens * feat(server): update the messages for delete endpoint --------- Co-authored-by: Helmut K. C. Tessarek Co-authored-by: Orhun Parmaksız --- README.md | 15 +++- config.toml | 4 + fixtures/test-file-delete/config.toml | 9 +++ fixtures/test-file-delete/test.sh | 19 +++++ src/config.rs | 72 ++++++++++++++--- src/lib.rs | 3 + src/server.rs | 107 ++++++++++++++++++++++++-- 7 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 fixtures/test-file-delete/config.toml create mode 100755 fixtures/test-file-delete/test.sh diff --git a/README.md b/README.md index b9a153a..2c12a91 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ Here you can read the blog post about how it is deployed on Shuttle: [https://bl - [URL shortening](#url-shortening) - [Paste file from remote URL](#paste-file-from-remote-url) - [Cleaning up expired files](#cleaning-up-expired-files) + - [Delete file from server](#delete-file-from-server) - [Server](#server) - - [List endpoint](#list-endpoint) + - [List endpoint](#list-endpoint) - [HTML Form](#html-form) - [Docker](#docker) - [Nginx](#nginx) @@ -78,7 +79,7 @@ Here you can read the blog post about how it is deployed on Shuttle: [https://bl - supports overriding and blacklisting - supports forcing to download via `?download=true` - no duplicate uploads (optional) - - listing files + - listing/deleting files - custom landing page - Single binary - [binary releases](https://github.com/orhun/rustypaste/releases) @@ -235,6 +236,14 @@ while read -r filename; do done ``` +#### Delete file from server + +Set `delete_tokens` array in [config.toml](./config.toml) to activate the [`DELETE`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) endpoint and secure it with one (or more) auth token(s). + +```sh +$ curl -H "Authorization: " -X DELETE "/file.txt" +``` + ### Server To start the server: @@ -260,7 +269,7 @@ You can also set multiple auth tokens via the array field `[server].auth_tokens` See [config.toml](./config.toml) for configuration options. -### List endpoint +#### List endpoint Set `expose_list` to true in [config.toml](./config.toml) to be able to retrieve a JSON formatted list of files in your uploads directory. This will not include oneshot files, oneshot URLs, or URLs. diff --git a/config.toml b/config.toml index 2b22d6b..a9d1ff2 100644 --- a/config.toml +++ b/config.toml @@ -14,6 +14,10 @@ expose_list = false # "super_secret_token1", # "super_secret_token2", #] +#delete_tokens = [ +# "super_secret_token1", +# "super_secret_token3", +#] handle_spaces = "replace" # or "encode" [landing_page] diff --git a/fixtures/test-file-delete/config.toml b/fixtures/test-file-delete/config.toml new file mode 100644 index 0000000..705991d --- /dev/null +++ b/fixtures/test-file-delete/config.toml @@ -0,0 +1,9 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" +delete_tokens = ["may_the_force_be_with_you"] + +[paste] +default_extension = "txt" +duplicate_files = true diff --git a/fixtures/test-file-delete/test.sh b/fixtures/test-file-delete/test.sh new file mode 100755 index 0000000..75fb087 --- /dev/null +++ b/fixtures/test-file-delete/test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +content="test data" + +setup() { + echo "$content" > file +} + +run_test() { + file_url=$(curl -s -F "file=@file" localhost:8000) + test "$file_url" = "http://localhost:8000/file.txt" + test "the file is deleted" = "$(curl -s -H "Authorization: may_the_force_be_with_you" -X DELETE http://localhost:8000/file.txt)" + test "file is not found or expired :(" = "$(curl -s -H "Authorization: may_the_force_be_with_you" -X DELETE http://localhost:8000/file.txt)" +} + +teardown() { + rm file + rm -r upload +} diff --git a/src/config.rs b/src/config.rs index 6d55c2d..537e3d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use crate::mime::MimeMatcher; use crate::random::RandomURLConfig; -use crate::AUTH_TOKEN_ENV; +use crate::{AUTH_TOKEN_ENV, DELETE_TOKEN_ENV}; use byte_unit::Byte; use config::{self, ConfigError}; use std::env; @@ -62,6 +62,8 @@ pub struct ServerConfig { pub handle_spaces: Option, /// Path of the JSON index. pub expose_list: Option, + /// Authentication tokens for deleting. + pub delete_tokens: Option>, } /// Enum representing different strategies for handling spaces in filenames. @@ -127,6 +129,14 @@ pub struct CleanupConfig { pub interval: Duration, } +/// Type of access token. +pub enum TokenType { + /// Token for authentication. + Auth, + /// Token for DELETE endpoint. + Delete, +} + impl Config { /// Parses the config file and returns the values. pub fn parse(path: &Path) -> Result { @@ -137,16 +147,29 @@ impl Config { .try_deserialize() } - /// Retrieves all configured tokens. - #[allow(deprecated)] - pub fn get_tokens(&self) -> Option> { - let mut tokens = self.server.auth_tokens.clone().unwrap_or_default(); - if let Some(token) = &self.server.auth_token { - tokens.insert(0, token.to_string()); - } - if let Ok(env_token) = env::var(AUTH_TOKEN_ENV) { - tokens.insert(0, env_token); - } + /// Retrieves all configured auth/delete tokens. + pub fn get_tokens(&self, token_type: TokenType) -> Option> { + let mut tokens = match token_type { + TokenType::Auth => { + let mut tokens = self.server.auth_tokens.clone().unwrap_or_default(); + #[allow(deprecated)] + if let Some(token) = &self.server.auth_token { + tokens.insert(0, token.to_string()); + } + if let Ok(env_token) = env::var(AUTH_TOKEN_ENV) { + tokens.insert(0, env_token); + } + tokens + } + TokenType::Delete => { + let mut tokens = self.server.delete_tokens.clone().unwrap_or_default(); + if let Ok(env_token) = env::var(DELETE_TOKEN_ENV) { + tokens.insert(0, env_token); + } + tokens + } + }; + tokens.retain(|v| !v.is_empty()); (!tokens.is_empty()).then_some(tokens) } @@ -211,4 +234,31 @@ mod tests { let encoded_filename = SpaceHandlingConfig::Encode.process_filename("file with spaces.txt"); assert_eq!("file%20with%20spaces.txt", encoded_filename); } + + #[test] + fn test_get_tokens() -> Result<(), ConfigError> { + let config_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.toml"); + env::set_var("AUTH_TOKEN", "env_auth"); + env::set_var("DELETE_TOKEN", "env_delete"); + let mut config = Config::parse(&config_path)?; + config.server.auth_tokens = Some(vec!["may_the_force_be_with_you".to_string()]); + config.server.delete_tokens = Some(vec!["i_am_your_father".to_string()]); + assert_eq!( + Some(vec![ + "env_auth".to_string(), + "may_the_force_be_with_you".to_string() + ]), + config.get_tokens(TokenType::Auth) + ); + assert_eq!( + Some(vec![ + "env_delete".to_string(), + "i_am_your_father".to_string() + ]), + config.get_tokens(TokenType::Delete) + ); + env::remove_var("AUTH_TOKEN"); + env::remove_var("DELETE_TOKEN"); + Ok(()) + } } diff --git a/src/lib.rs b/src/lib.rs index 647d479..035f398 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,3 +36,6 @@ pub const CONFIG_ENV: &str = "CONFIG"; /// Environment variable for setting the authentication token. pub const AUTH_TOKEN_ENV: &str = "AUTH_TOKEN"; + +/// Environment variable for setting the deletion token. +pub const DELETE_TOKEN_ENV: &str = "DELETE_TOKEN"; diff --git a/src/server.rs b/src/server.rs index 4c434a6..819881f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,5 +1,5 @@ use crate::auth; -use crate::config::{Config, LandingPageConfig}; +use crate::config::{Config, LandingPageConfig, TokenType}; use crate::file::Directory; use crate::header::{self, ContentDisposition}; use crate::mime as mime_util; @@ -7,7 +7,7 @@ use crate::paste::{Paste, PasteType}; use crate::util; use actix_files::NamedFile; use actix_multipart::Multipart; -use actix_web::{error, get, post, web, Error, HttpRequest, HttpResponse}; +use actix_web::{delete, error, get, post, web, Error, HttpRequest, HttpResponse}; use awc::Client; use byte_unit::Byte; use futures_util::stream::StreamExt; @@ -146,6 +146,39 @@ async fn serve( } } +/// Remove a file from the upload directory. +#[delete("/{file}")] +async fn delete( + request: HttpRequest, + file: web::Path, + config: web::Data>, +) -> Result { + let config = config + .read() + .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; + let path = config.server.upload_path.join(&*file); + let path = util::glob_match_file(path)?; + let connection = request.connection_info().clone(); + let host = connection.realip_remote_addr().unwrap_or("unknown host"); + let tokens = config.get_tokens(TokenType::Delete); + if tokens.is_none() { + log::warn!("delete endpoint is not served because there are no delete_tokens set"); + return Err(error::ErrorForbidden("endpoint is not exposed\n")); + } + auth::check(host, request.headers(), tokens)?; + if !path.is_file() || !path.exists() { + return Err(error::ErrorNotFound("file is not found or expired :(\n")); + } + match fs::remove_file(path) { + Ok(_) => log::info!("deleted file: {:?}", file), + Err(e) => { + log::error!("cannot delete file: {}", e); + return Err(error::ErrorInternalServerError("cannot delete file")); + } + } + Ok(HttpResponse::Ok().body(String::from("the file is deleted\n"))) +} + /// Expose version endpoint #[get("/version")] async fn version( @@ -157,7 +190,7 @@ async fn version( .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; let connection = request.connection_info().clone(); let host = connection.realip_remote_addr().unwrap_or("unknown host"); - let tokens = config.get_tokens(); + let tokens = config.get_tokens(TokenType::Auth); auth::check(host, request.headers(), tokens)?; if !config.server.expose_version.unwrap_or(false) { log::warn!("server is not configured to expose version endpoint"); @@ -181,7 +214,7 @@ async fn upload( let config = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; - let tokens = config.get_tokens(); + let tokens = config.get_tokens(TokenType::Auth); auth::check(host, request.headers(), tokens)?; } let server_url = match config @@ -315,7 +348,7 @@ async fn list( .clone(); let connection = request.connection_info().clone(); let host = connection.realip_remote_addr().unwrap_or("unknown host"); - let tokens = config.get_tokens(); + let tokens = config.get_tokens(TokenType::Auth); auth::check(host, request.headers(), tokens)?; if !config.server.expose_list.unwrap_or(false) { log::warn!("server is not configured to expose list endpoint"); @@ -370,6 +403,7 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .service(list) .service(serve) .service(upload) + .service(delete) .route("", web::head().to(HttpResponse::MethodNotAllowed)); } @@ -382,6 +416,7 @@ mod tests { use actix_web::body::MessageBody; use actix_web::body::{BodySize, BoxBody}; use actix_web::error::Error; + use actix_web::http::header::AUTHORIZATION; use actix_web::http::{header, StatusCode}; use actix_web::test::{self, TestRequest}; use actix_web::web::Data; @@ -724,6 +759,68 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn test_delete_file() -> Result<(), Error> { + let mut config = Config::default(); + config.server.delete_tokens = Some(vec!["test".to_string()]); + config.server.upload_path = env::current_dir()?; + + let app = test::init_service( + App::new() + .app_data(Data::new(RwLock::new(config))) + .app_data(Data::new(Client::default())) + .configure(configure_routes), + ) + .await; + + let file_name = "test_file.txt"; + let timestamp = util::get_system_time()?.as_secs().to_string(); + test::call_service( + &app, + get_multipart_request(×tamp, "file", file_name).to_request(), + ) + .await; + + let request = TestRequest::delete() + .insert_header((AUTHORIZATION, header::HeaderValue::from_static("test"))) + .uri(&format!("/{file_name}")) + .to_request(); + let response = test::call_service(&app, request).await; + + assert_eq!(StatusCode::OK, response.status()); + + let path = PathBuf::from(file_name); + assert!(!path.exists()); + + Ok(()) + } + + #[actix_web::test] + async fn test_delete_file_without_token_in_config() -> Result<(), Error> { + let mut config = Config::default(); + config.server.upload_path = env::current_dir()?; + + let app = test::init_service( + App::new() + .app_data(Data::new(RwLock::new(config))) + .app_data(Data::new(Client::default())) + .configure(configure_routes), + ) + .await; + + let file_name = "test_file.txt"; + let request = TestRequest::delete() + .insert_header((AUTHORIZATION, header::HeaderValue::from_static("test"))) + .uri(&format!("/{file_name}")) + .to_request(); + let response = test::call_service(&app, request).await; + + assert_eq!(StatusCode::FORBIDDEN, response.status()); + assert_body(response.into_body(), "endpoint is not exposed\n").await?; + + Ok(()) + } + #[actix_web::test] async fn test_upload_file() -> Result<(), Error> { let mut config = Config::default();