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)
|
||||
- [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.
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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::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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
107
src/server.rs
107
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<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(×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();
|
||||
|
|
Loading…
Reference in New Issue