feat(server): add delete endpoint (#136)
* 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 <orhunparmaksiz@gmail.com> * Update README.md Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com> * refactor(server): use log::error! and return 500 Co-authored-by: Helmut K. C. Tessarek <tessarek@evermeet.cx> * 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 <tessarek@evermeet.cx> * 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 <tessarek@evermeet.cx> Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
This commit is contained in:
parent
2292c24aaf
commit
27e3be5a2d
15
README.md
15
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)
|
- [URL shortening](#url-shortening)
|
||||||
- [Paste file from remote URL](#paste-file-from-remote-url)
|
- [Paste file from remote URL](#paste-file-from-remote-url)
|
||||||
- [Cleaning up expired files](#cleaning-up-expired-files)
|
- [Cleaning up expired files](#cleaning-up-expired-files)
|
||||||
|
- [Delete file from server](#delete-file-from-server)
|
||||||
- [Server](#server)
|
- [Server](#server)
|
||||||
- [List endpoint](#list-endpoint)
|
- [List endpoint](#list-endpoint)
|
||||||
- [HTML Form](#html-form)
|
- [HTML Form](#html-form)
|
||||||
- [Docker](#docker)
|
- [Docker](#docker)
|
||||||
- [Nginx](#nginx)
|
- [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 overriding and blacklisting
|
||||||
- supports forcing to download via `?download=true`
|
- supports forcing to download via `?download=true`
|
||||||
- no duplicate uploads (optional)
|
- no duplicate uploads (optional)
|
||||||
- listing files
|
- listing/deleting files
|
||||||
- custom landing page
|
- custom landing page
|
||||||
- Single binary
|
- Single binary
|
||||||
- [binary releases](https://github.com/orhun/rustypaste/releases)
|
- [binary releases](https://github.com/orhun/rustypaste/releases)
|
||||||
|
@ -235,6 +236,14 @@ while read -r filename; do
|
||||||
done
|
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: <auth_token>" -X DELETE "<server_address>/file.txt"
|
||||||
|
```
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
To start the 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,10 @@ expose_list = false
|
||||||
# "super_secret_token1",
|
# "super_secret_token1",
|
||||||
# "super_secret_token2",
|
# "super_secret_token2",
|
||||||
#]
|
#]
|
||||||
|
#delete_tokens = [
|
||||||
|
# "super_secret_token1",
|
||||||
|
# "super_secret_token3",
|
||||||
|
#]
|
||||||
handle_spaces = "replace" # or "encode"
|
handle_spaces = "replace" # or "encode"
|
||||||
|
|
||||||
[landing_page]
|
[landing_page]
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::mime::MimeMatcher;
|
use crate::mime::MimeMatcher;
|
||||||
use crate::random::RandomURLConfig;
|
use crate::random::RandomURLConfig;
|
||||||
use crate::AUTH_TOKEN_ENV;
|
use crate::{AUTH_TOKEN_ENV, DELETE_TOKEN_ENV};
|
||||||
use byte_unit::Byte;
|
use byte_unit::Byte;
|
||||||
use config::{self, ConfigError};
|
use config::{self, ConfigError};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
@ -62,6 +62,8 @@ pub struct ServerConfig {
|
||||||
pub handle_spaces: Option<SpaceHandlingConfig>,
|
pub handle_spaces: Option<SpaceHandlingConfig>,
|
||||||
/// Path of the JSON index.
|
/// Path of the JSON index.
|
||||||
pub expose_list: Option<bool>,
|
pub expose_list: Option<bool>,
|
||||||
|
/// Authentication tokens for deleting.
|
||||||
|
pub delete_tokens: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enum representing different strategies for handling spaces in filenames.
|
/// Enum representing different strategies for handling spaces in filenames.
|
||||||
|
@ -127,6 +129,14 @@ pub struct CleanupConfig {
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Type of access token.
|
||||||
|
pub enum TokenType {
|
||||||
|
/// Token for authentication.
|
||||||
|
Auth,
|
||||||
|
/// Token for DELETE endpoint.
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Parses the config file and returns the values.
|
/// Parses the config file and returns the values.
|
||||||
pub fn parse(path: &Path) -> Result<Config, ConfigError> {
|
pub fn parse(path: &Path) -> Result<Config, ConfigError> {
|
||||||
|
@ -137,16 +147,29 @@ impl Config {
|
||||||
.try_deserialize()
|
.try_deserialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves all configured tokens.
|
/// Retrieves all configured auth/delete tokens.
|
||||||
#[allow(deprecated)]
|
pub fn get_tokens(&self, token_type: TokenType) -> Option<Vec<String>> {
|
||||||
pub fn get_tokens(&self) -> Option<Vec<String>> {
|
let mut tokens = match token_type {
|
||||||
let mut tokens = self.server.auth_tokens.clone().unwrap_or_default();
|
TokenType::Auth => {
|
||||||
if let Some(token) = &self.server.auth_token {
|
let mut tokens = self.server.auth_tokens.clone().unwrap_or_default();
|
||||||
tokens.insert(0, token.to_string());
|
#[allow(deprecated)]
|
||||||
}
|
if let Some(token) = &self.server.auth_token {
|
||||||
if let Ok(env_token) = env::var(AUTH_TOKEN_ENV) {
|
tokens.insert(0, token.to_string());
|
||||||
tokens.insert(0, env_token);
|
}
|
||||||
}
|
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)
|
(!tokens.is_empty()).then_some(tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,4 +234,31 @@ mod tests {
|
||||||
let encoded_filename = SpaceHandlingConfig::Encode.process_filename("file with spaces.txt");
|
let encoded_filename = SpaceHandlingConfig::Encode.process_filename("file with spaces.txt");
|
||||||
assert_eq!("file%20with%20spaces.txt", encoded_filename);
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,3 +36,6 @@ pub const CONFIG_ENV: &str = "CONFIG";
|
||||||
|
|
||||||
/// Environment variable for setting the authentication token.
|
/// Environment variable for setting the authentication token.
|
||||||
pub const AUTH_TOKEN_ENV: &str = "AUTH_TOKEN";
|
pub const AUTH_TOKEN_ENV: &str = "AUTH_TOKEN";
|
||||||
|
|
||||||
|
/// Environment variable for setting the deletion token.
|
||||||
|
pub const DELETE_TOKEN_ENV: &str = "DELETE_TOKEN";
|
||||||
|
|
107
src/server.rs
107
src/server.rs
|
@ -1,5 +1,5 @@
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::config::{Config, LandingPageConfig};
|
use crate::config::{Config, LandingPageConfig, TokenType};
|
||||||
use crate::file::Directory;
|
use crate::file::Directory;
|
||||||
use crate::header::{self, ContentDisposition};
|
use crate::header::{self, ContentDisposition};
|
||||||
use crate::mime as mime_util;
|
use crate::mime as mime_util;
|
||||||
|
@ -7,7 +7,7 @@ use crate::paste::{Paste, PasteType};
|
||||||
use crate::util;
|
use crate::util;
|
||||||
use actix_files::NamedFile;
|
use actix_files::NamedFile;
|
||||||
use actix_multipart::Multipart;
|
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 awc::Client;
|
||||||
use byte_unit::Byte;
|
use byte_unit::Byte;
|
||||||
use futures_util::stream::StreamExt;
|
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<String>,
|
||||||
|
config: web::Data<RwLock<Config>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
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
|
/// Expose version endpoint
|
||||||
#[get("/version")]
|
#[get("/version")]
|
||||||
async fn version(
|
async fn version(
|
||||||
|
@ -157,7 +190,7 @@ async fn version(
|
||||||
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
|
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
|
||||||
let connection = request.connection_info().clone();
|
let connection = request.connection_info().clone();
|
||||||
let host = connection.realip_remote_addr().unwrap_or("unknown host");
|
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)?;
|
auth::check(host, request.headers(), tokens)?;
|
||||||
if !config.server.expose_version.unwrap_or(false) {
|
if !config.server.expose_version.unwrap_or(false) {
|
||||||
log::warn!("server is not configured to expose version endpoint");
|
log::warn!("server is not configured to expose version endpoint");
|
||||||
|
@ -181,7 +214,7 @@ async fn upload(
|
||||||
let config = config
|
let config = config
|
||||||
.read()
|
.read()
|
||||||
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
|
.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)?;
|
auth::check(host, request.headers(), tokens)?;
|
||||||
}
|
}
|
||||||
let server_url = match config
|
let server_url = match config
|
||||||
|
@ -315,7 +348,7 @@ async fn list(
|
||||||
.clone();
|
.clone();
|
||||||
let connection = request.connection_info().clone();
|
let connection = request.connection_info().clone();
|
||||||
let host = connection.realip_remote_addr().unwrap_or("unknown host");
|
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)?;
|
auth::check(host, request.headers(), tokens)?;
|
||||||
if !config.server.expose_list.unwrap_or(false) {
|
if !config.server.expose_list.unwrap_or(false) {
|
||||||
log::warn!("server is not configured to expose list endpoint");
|
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(list)
|
||||||
.service(serve)
|
.service(serve)
|
||||||
.service(upload)
|
.service(upload)
|
||||||
|
.service(delete)
|
||||||
.route("", web::head().to(HttpResponse::MethodNotAllowed));
|
.route("", web::head().to(HttpResponse::MethodNotAllowed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,6 +416,7 @@ mod tests {
|
||||||
use actix_web::body::MessageBody;
|
use actix_web::body::MessageBody;
|
||||||
use actix_web::body::{BodySize, BoxBody};
|
use actix_web::body::{BodySize, BoxBody};
|
||||||
use actix_web::error::Error;
|
use actix_web::error::Error;
|
||||||
|
use actix_web::http::header::AUTHORIZATION;
|
||||||
use actix_web::http::{header, StatusCode};
|
use actix_web::http::{header, StatusCode};
|
||||||
use actix_web::test::{self, TestRequest};
|
use actix_web::test::{self, TestRequest};
|
||||||
use actix_web::web::Data;
|
use actix_web::web::Data;
|
||||||
|
@ -724,6 +759,68 @@ mod tests {
|
||||||
Ok(())
|
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]
|
#[actix_web::test]
|
||||||
async fn test_upload_file() -> Result<(), Error> {
|
async fn test_upload_file() -> Result<(), Error> {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
|
|
Loading…
Reference in New Issue