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:
Andy Baird 2023-09-03 10:47:52 -05:00 committed by GitHub
parent 2292c24aaf
commit 27e3be5a2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 210 additions and 19 deletions

View File

@ -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: <auth_token>" -X DELETE "<server_address>/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.

View File

@ -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]

View File

@ -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

View File

@ -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
}

View File

@ -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<SpaceHandlingConfig>,
/// Path of the JSON index.
pub expose_list: Option<bool>,
/// Authentication tokens for deleting.
pub delete_tokens: Option<Vec<String>>,
}
/// 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<Config, ConfigError> {
@ -137,16 +147,29 @@ impl Config {
.try_deserialize()
}
/// Retrieves all configured tokens.
#[allow(deprecated)]
pub fn get_tokens(&self) -> Option<Vec<String>> {
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<Vec<String>> {
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(())
}
}

View File

@ -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";

View File

@ -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<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
#[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(&timestamp, "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();