Compare commits

...

10 Commits

Author SHA1 Message Date
minish 9fdc2345f9 fix uploads being owned by root + other minor changes 2023-06-11 03:46:52 +00:00
minish b1bee30e23 rust 1.67 was getting stuck on updating crates.io index 2023-06-10 05:07:03 +00:00
minish 3dcea58419 remove comment from cargo.toml 2023-02-28 23:20:57 +00:00
minish 527e3c37ab bump version 2023-02-10 18:58:23 -05:00
minish 176cd813b9 add robots.txt 2023-02-10 18:57:56 -05:00
minish d0a739386f dockerfile 2023-02-02 18:29:36 -05:00
minish 53b0b95554 small tweaks 2023-02-02 18:05:30 -05:00
minish 08826148bb add readme 2023-02-01 20:25:38 -05:00
minish 2662128bbb mostly just auth 2023-02-01 19:05:13 -05:00
minish a3c69ef914 fix upload count and error responses 2023-01-30 19:14:25 -05:00
9 changed files with 183 additions and 51 deletions

2
Cargo.lock generated
View File

@ -129,7 +129,7 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "breeze" name = "breeze"
version = "0.1.1" version = "0.1.3"
dependencies = [ dependencies = [
"archived", "archived",
"async-recursion", "async-recursion",

View File

@ -1,10 +1,8 @@
[package] [package]
name = "breeze" name = "breeze"
version = "0.1.1" version = "0.1.3"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
axum = { version = "0.6.1", features = ["macros"] } axum = { version = "0.6.1", features = ["macros"] }
hyper = { version = "0.14", features = ["full"] } hyper = { version = "0.14", features = ["full"] }

View File

@ -1,10 +1,19 @@
FROM rust:1.67.0 as builder # builder
FROM rust:1.70 as builder
WORKDIR /usr/src/breeze
COPY . . WORKDIR /usr/src/breeze
RUN [ "cargo", "install", "--path", "." ] COPY . .
RUN cargo install --path .
FROM debian:bullseye-slim
COPY --from=builder /usr/local/cargo/bin/breeze /usr/local/bin/breeze # runner
FROM debian:bullseye-slim
ENTRYPOINT [ "breeze" ]
RUN apt-get update && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/breeze /usr/local/bin/breeze
RUN useradd -m runner
USER runner
EXPOSE 8000
CMD [ "breeze" ]

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# breeze
breeze is a simple, heavily optimised file upload server.
## Features
Compared to the old Express.js backend, breeze has
- Streamed uploading
- Streamed downloading (on larger files)
- Upload caching
- Generally faster speeds overall
At this time, breeze does not support encrypted uploads on disk.
## Installation
I wrote breeze with the intention of running it in a container, but it runs just fine outside of one.
Either way, you need to start off by cloning the Git repository.
```bash
git clone https://git.maple.vin/minish/breeze.git
```
To run it in Docker, you need to build an image of it.
```bash
docker build -t breeze .
```
From there, you can make a `docker-compose.yaml` file with your configuration and run it using `docker-compose up`.
It can also be installed directly if you have the Rust toolchain installed
```bash
cargo install --path .
```
## Usage
### Hosting
Configuration is read through environment variables, because I wanted to run this using `docker-compose`.
```
BRZ_BASE_URL - base url for upload urls (ex: http://127.0.0.1:8000 for http://127.0.0.1:8000/p/abcdef.png, http://picture.wtf for http://picture.wtf/p/abcdef.png)
BRZ_SAVE_PATH - this should be a path where uploads are saved to disk (ex: /srv/uploads, C:\brzuploads)
BRZ_UPLOAD_KEY (optional) - if not empty, the key you specify will be required to upload new files.
BRZ_CACHE_UPL_MAX_LENGTH - this is the max length an upload can be in bytes before it won't be cached (ex: 80000000 for 80MB)
BRZ_CACHE_UPL_LIFETIME - this indicates how long an upload will stay in cache (ex: 1800 for 30 minutes, 60 for 1 minute)
BRZ_CACHE_SCAN_FREQ - this is the frequency of full cache scans, which scan for and remove expired uploads (ex: 60 for 1 minute)
BRZ_CACHE_MEM_CAPACITY - this is the amount of memory the cache will hold before dropping entries
```
### Uploading
The HTTP API is fairly simple, and it's pretty easy to make a ShareX configuration for it.
Uploads should be sent to `/new?name={original filename}` as a POST request. If the server uses upload keys, it should be sent to `/new?name={original filename}&key={upload key}`. The uploaded file's content should be sent as raw binary in the request body.
Here's an example ShareX configuration for it (with a key):
```json
{
"Version": "14.1.0",
"Name": "breeze example",
"DestinationType": "ImageUploader, TextUploader, FileUploader",
"RequestMethod": "POST",
"RequestURL": "http://127.0.0.1:8000/new",
"Parameters": {
"name": "{filename}",
"key": "hiiiiiiii"
},
"Body": "Binary"
}
```

View File

@ -8,7 +8,6 @@ use std::{
use archived::Archive; use archived::Archive;
use axum::extract::BodyStream; use axum::extract::BodyStream;
use bytes::{BufMut, Bytes, BytesMut}; use bytes::{BufMut, Bytes, BytesMut};
use hyper::StatusCode;
use rand::Rng; use rand::Rng;
use tokio::{ use tokio::{
fs::File, fs::File,
@ -21,16 +20,17 @@ use tokio::{
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::view::ViewResponse; use crate::view::{ViewError, ViewSuccess};
pub struct Engine { pub struct Engine {
// state // state
cache: RwLock<Archive>, // in-memory cache. note/ i plan to lock the cache specifically only when needed rather than locking the whole struct cache: RwLock<Archive>, // in-memory cache
pub upl_count: AtomicUsize, // cached count of uploaded files pub upl_count: AtomicUsize, // cached count of uploaded files
// config // config
pub base_url: String, // base url for formatting upload urls pub base_url: String, // base url for formatting upload urls
save_path: PathBuf, // where uploads are saved to disk save_path: PathBuf, // where uploads are saved to disk
pub upload_key: String, // authorisation key for uploading new files
cache_max_length: usize, // if an upload is bigger than this size, it won't be cached cache_max_length: usize, // if an upload is bigger than this size, it won't be cached
} }
@ -40,6 +40,7 @@ impl Engine {
pub fn new( pub fn new(
base_url: String, base_url: String,
save_path: PathBuf, save_path: PathBuf,
upload_key: String,
cache_max_length: usize, cache_max_length: usize,
cache_lifetime: Duration, cache_lifetime: Duration,
cache_full_scan_freq: Duration, // how often the cache will be scanned for expired items cache_full_scan_freq: Duration, // how often the cache will be scanned for expired items
@ -51,10 +52,11 @@ impl Engine {
cache_lifetime, cache_lifetime,
cache_mem_capacity, cache_mem_capacity,
)), )),
upl_count: AtomicUsize::new(WalkDir::new(&save_path).into_iter().count()), // count the amount of files in the save path and initialise our cached count with it upl_count: AtomicUsize::new(WalkDir::new(&save_path).min_depth(1).into_iter().count()), // count the amount of files in the save path and initialise our cached count with it
base_url, base_url,
save_path, save_path,
upload_key,
cache_max_length, cache_max_length,
} }
@ -142,11 +144,11 @@ impl Engine {
// create file to save upload to // create file to save upload to
let mut file = File::create(path) let mut file = File::create(path)
.await .await
.expect("could not open file! make sure your upload path exists"); .expect("could not open file! make sure your upload path is valid");
// receive chunks and save them to file // receive chunks and save them to file
while let Some(chunk) = rx.recv().await { while let Some(chunk) = rx.recv().await {
debug!(target: "process_upload", "writing chunk to disk (length: {})", chunk.len()); debug!("writing chunk to disk (length: {})", chunk.len());
file.write_all(&chunk) file.write_all(&chunk)
.await .await
.expect("error while writing file to disk"); .expect("error while writing file to disk");
@ -158,15 +160,15 @@ impl Engine {
let chunk = chunk.unwrap(); let chunk = chunk.unwrap();
// send chunk to io task // send chunk to io task
debug!(target: "process_upload", "sending data to io task"); debug!("sending data to io task");
tx.send(chunk.clone()) tx.send(chunk.clone())
.await .await
.expect("failed to send data to io task"); .expect("failed to send data to io task");
if use_cache { if use_cache {
debug!(target: "process_upload", "receiving data into buffer"); debug!("receiving data into buffer");
if data.len() + chunk.len() > data.capacity() { if data.len() + chunk.len() > data.capacity() {
error!(target: "process_upload", "the amount of data sent exceeds the content-length provided by the client! caching will be cancelled for this upload."); error!("the amount of data sent exceeds the content-length provided by the client! caching will be cancelled for this upload.");
// if we receive too much data, drop the buffer and stop using cache (it is still okay to use disk, probably) // if we receive too much data, drop the buffer and stop using cache (it is still okay to use disk, probably)
data = BytesMut::new(); data = BytesMut::new();
@ -181,10 +183,12 @@ impl Engine {
if use_cache { if use_cache {
let mut cache = self.cache.write().await; let mut cache = self.cache.write().await;
info!(target: "process_upload", "caching upload!"); info!("caching upload!");
cache.insert(name, data.freeze()); cache.insert(name, data.freeze());
} }
info!("finished processing upload!!");
// if all goes well, increment the cached upload counter // if all goes well, increment the cached upload counter
self.upl_count.fetch_add(1, Ordering::Relaxed); self.upl_count.fetch_add(1, Ordering::Relaxed);
} }
@ -208,7 +212,7 @@ impl Engine {
Some(data) Some(data)
} }
pub async fn get_upload(&self, original_path: &PathBuf) -> Result<ViewResponse, StatusCode> { pub async fn get_upload(&self, original_path: &PathBuf) -> Result<ViewSuccess, ViewError> {
// extract upload file name // extract upload file name
let name = original_path let name = original_path
.file_name() .file_name()
@ -222,16 +226,16 @@ impl Engine {
// check if the upload exists, if not then 404 // check if the upload exists, if not then 404
if !self.upload_exists(&path).await { if !self.upload_exists(&path).await {
return Err(StatusCode::NOT_FOUND); return Err(ViewError::NotFound);
} }
// attempt to read upload from cache // attempt to read upload from cache
let cached_data = self.read_cached_upload(&name).await; let cached_data = self.read_cached_upload(&name).await;
if let Some(data) = cached_data { if let Some(data) = cached_data {
info!(target: "get_upload", "got upload from cache!!"); info!("got upload from cache!!");
return Ok(ViewResponse::FromCache(data)); return Ok(ViewSuccess::FromCache(data));
} else { } else {
let mut file = File::open(&path).await.unwrap(); let mut file = File::open(&path).await.unwrap();
@ -242,7 +246,7 @@ impl Engine {
.expect("failed to read upload file metadata") .expect("failed to read upload file metadata")
.len() as usize; .len() as usize;
debug!(target: "get_upload", "read upload from disk, size = {}", length); debug!("read upload from disk, size = {}", length);
// if the upload is okay to cache, recache it and send a fromcache response // if the upload is okay to cache, recache it and send a fromcache response
if self.will_use_cache(length) { if self.will_use_cache(length) {
@ -258,8 +262,8 @@ impl Engine {
} }
} }
Err(_) => { Err(_) => {
return Err(StatusCode::INTERNAL_SERVER_ERROR); return Err(ViewError::InternalServerError);
}, }
} }
} }
@ -269,14 +273,14 @@ impl Engine {
let mut cache = self.cache.write().await; let mut cache = self.cache.write().await;
cache.insert(name, data.clone()); cache.insert(name, data.clone());
info!(target: "get_upload", "recached upload from disk!"); info!("recached upload from disk!");
return Ok(ViewResponse::FromCache(data)); return Ok(ViewSuccess::FromCache(data));
} }
info!(target: "get_upload", "got upload from disk!"); info!("got upload from disk!");
return Ok(ViewResponse::FromDisk(file)); return Ok(ViewSuccess::FromDisk(file));
} }
} }
} }

View File

@ -8,3 +8,14 @@ pub async fn index(State(engine): State<Arc<crate::engine::Engine>>) -> String {
format!("minish's image host, currently hosting {} files", count) format!("minish's image host, currently hosting {} files", count)
} }
// robots.txt that tells web crawlers not to list uploads
const ROBOTS_TXT: &'static str = concat!(
"User-Agent: *\n",
"Disallow: /p/*\n",
"Allow: /\n"
);
pub async fn robots_txt() -> &'static str {
ROBOTS_TXT
}

View File

@ -26,6 +26,7 @@ async fn main() {
// read env vars // read env vars
let base_url = env::var("BRZ_BASE_URL").expect("missing BRZ_BASE_URL! base url for upload urls (ex: http://127.0.0.1:8000 for http://127.0.0.1:8000/p/abcdef.png, http://picture.wtf for http://picture.wtf/p/abcdef.png)"); let base_url = env::var("BRZ_BASE_URL").expect("missing BRZ_BASE_URL! base url for upload urls (ex: http://127.0.0.1:8000 for http://127.0.0.1:8000/p/abcdef.png, http://picture.wtf for http://picture.wtf/p/abcdef.png)");
let save_path = env::var("BRZ_SAVE_PATH").expect("missing BRZ_SAVE_PATH! this should be a path where uploads are saved to disk (ex: /srv/uploads, C:\\brzuploads)"); let save_path = env::var("BRZ_SAVE_PATH").expect("missing BRZ_SAVE_PATH! this should be a path where uploads are saved to disk (ex: /srv/uploads, C:\\brzuploads)");
let upload_key = env::var("BRZ_UPLOAD_KEY").unwrap_or_default();
let cache_max_length = env::var("BRZ_CACHE_UPL_MAX_LENGTH").expect("missing BRZ_CACHE_UPL_MAX_LENGTH! this is the max length an upload can be in bytes before it won't be cached (ex: 80000000 for 80MB)"); let cache_max_length = env::var("BRZ_CACHE_UPL_MAX_LENGTH").expect("missing BRZ_CACHE_UPL_MAX_LENGTH! this is the max length an upload can be in bytes before it won't be cached (ex: 80000000 for 80MB)");
let cache_upl_lifetime = env::var("BRZ_CACHE_UPL_LIFETIME").expect("missing BRZ_CACHE_UPL_LIFETIME! this indicates how long an upload will stay in cache (ex: 1800 for 30 minutes, 60 for 1 minute)"); let cache_upl_lifetime = env::var("BRZ_CACHE_UPL_LIFETIME").expect("missing BRZ_CACHE_UPL_LIFETIME! this indicates how long an upload will stay in cache (ex: 1800 for 30 minutes, 60 for 1 minute)");
let cache_scan_freq = env::var("BRZ_CACHE_SCAN_FREQ").expect("missing BRZ_CACHE_SCAN_FREQ! this is the frequency of full cache scans, which scan for and remove expired uploads (ex: 60 for 1 minute)"); let cache_scan_freq = env::var("BRZ_CACHE_SCAN_FREQ").expect("missing BRZ_CACHE_SCAN_FREQ! this is the frequency of full cache scans, which scan for and remove expired uploads (ex: 60 for 1 minute)");
@ -39,13 +40,19 @@ async fn main() {
let cache_mem_capacity = usize::from_str_radix(&cache_mem_capacity, 10).expect("failed parsing BRZ_CACHE_MEM_CAPACITY! it should be a positive number without any separators"); let cache_mem_capacity = usize::from_str_radix(&cache_mem_capacity, 10).expect("failed parsing BRZ_CACHE_MEM_CAPACITY! it should be a positive number without any separators");
if !save_path.exists() || !save_path.is_dir() { if !save_path.exists() || !save_path.is_dir() {
panic!("the save path does not exist or is not a directory. this is invalid"); panic!("the save path does not exist or is not a directory! this is invalid");
}
if upload_key.is_empty() {
// i would prefer this to be a warning but the default log level hides those
error!("upload key (BRZ_UPLOAD_KEY) is empty! no key will be required for uploading new files");
} }
// create engine // create engine
let engine = Engine::new( let engine = Engine::new(
base_url, base_url,
save_path, save_path,
upload_key,
cache_max_length, cache_max_length,
cache_upl_lifetime, cache_upl_lifetime,
cache_scan_freq, cache_scan_freq,
@ -57,6 +64,7 @@ async fn main() {
.route("/new", post(new::new)) .route("/new", post(new::new))
.route("/p/:name", get(view::view)) .route("/p/:name", get(view::view))
.route("/", get(index::index)) .route("/", get(index::index))
.route("/robots.txt", get(index::robots_txt))
.with_state(Arc::new(engine)); .with_state(Arc::new(engine));
// start web server // start web server
@ -71,7 +79,7 @@ async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()
.await .await
.expect("failed to add ctrl-c handler"); .expect("failed to add SIGINT handler");
}; };
#[cfg(unix)] #[cfg(unix)]

View File

@ -4,7 +4,7 @@ use axum::{
extract::{BodyStream, Query, State}, extract::{BodyStream, Query, State},
http::HeaderValue, http::HeaderValue,
}; };
use hyper::{HeaderMap, StatusCode, header}; use hyper::{header, HeaderMap, StatusCode};
#[axum::debug_handler] #[axum::debug_handler]
pub async fn new( pub async fn new(
@ -13,12 +13,21 @@ pub async fn new(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
stream: BodyStream, stream: BodyStream,
) -> Result<String, StatusCode> { ) -> Result<String, StatusCode> {
if !params.contains_key("name") { let key = params.get("key");
// check upload key, if i need to
if !engine.upload_key.is_empty() && key.unwrap_or(&String::new()) != &engine.upload_key {
return Err(StatusCode::FORBIDDEN);
}
let original_name = params.get("name");
// the original file name wasn't given, so i can't work out what the extension should be
if original_name.is_none() {
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
let original_name = params.get("name").unwrap(); let original_path = PathBuf::from(original_name.unwrap());
let original_path = PathBuf::from(original_name);
let path = engine.gen_path(&original_path).await; let path = engine.gen_path(&original_path).await;
let name = path let name = path
@ -29,6 +38,7 @@ pub async fn new(
let url = format!("{}/p/{}", engine.base_url, name); let url = format!("{}/p/{}", engine.base_url, name);
// read and parse content-length, and if it fails just assume it's really high so it doesn't cache
let content_length = headers let content_length = headers
.get(header::CONTENT_LENGTH) .get(header::CONTENT_LENGTH)
.unwrap_or(&HeaderValue::from_static("")) .unwrap_or(&HeaderValue::from_static(""))
@ -37,9 +47,10 @@ pub async fn new(
.unwrap() .unwrap()
.unwrap_or(usize::MAX); .unwrap_or(usize::MAX);
// pass it off to the engine to be processed!
engine engine
.process_upload(path, name, content_length, stream) .process_upload(path, name, content_length, stream)
.await; .await;
Ok(url) Ok(url)
} }

View File

@ -14,22 +14,27 @@ use hyper::StatusCode;
use tokio::fs::File; use tokio::fs::File;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
pub enum ViewResponse { pub enum ViewSuccess {
FromDisk(File), FromDisk(File),
FromCache(Bytes), FromCache(Bytes),
} }
impl IntoResponse for ViewResponse { pub enum ViewError {
NotFound, // 404
InternalServerError, // 500
}
impl IntoResponse for ViewSuccess {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
ViewResponse::FromDisk(file) => { ViewSuccess::FromDisk(file) => {
// create a streamed body response (we want to stream larger files) // create a streamed body response (we want to stream larger files)
let reader = ReaderStream::new(file); let reader = ReaderStream::new(file);
let stream = StreamBody::new(reader); let stream = StreamBody::new(reader);
stream.into_response() stream.into_response()
} }
ViewResponse::FromCache(data) => { ViewSuccess::FromCache(data) => {
// extract mutable headers from the response // extract mutable headers from the response
let mut res = data.into_response(); let mut res = data.into_response();
let headers = res.headers_mut(); let headers = res.headers_mut();
@ -43,20 +48,42 @@ impl IntoResponse for ViewResponse {
} }
} }
impl IntoResponse for ViewError {
fn into_response(self) -> Response {
match self {
ViewError::NotFound => {
// convert string into response, change status code
let mut res = "not found!".into_response();
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
ViewError::InternalServerError => {
// convert string into response, change status code
let mut res = "internal server error!".into_response();
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
}
}
}
#[axum::debug_handler] #[axum::debug_handler]
pub async fn view( pub async fn view(
State(engine): State<Arc<crate::engine::Engine>>, State(engine): State<Arc<crate::engine::Engine>>,
Path(original_path): Path<PathBuf>, Path(original_path): Path<PathBuf>,
) -> Result<ViewResponse, StatusCode> { ) -> Result<ViewSuccess, ViewError> {
// (hopefully) prevent path traversal, just check for any non-file components // (hopefully) prevent path traversal, just check for any non-file components
if original_path if original_path
.components() .components()
.into_iter() .into_iter()
.any(|x| !matches!(x, Component::Normal(_))) .any(|x| !matches!(x, Component::Normal(_)))
{ {
warn!(target: "view", "a request attempted path traversal"); warn!("a request attempted path traversal");
return Err(StatusCode::NOT_FOUND); return Err(ViewError::NotFound);
} }
// get result from the engine!
engine.get_upload(&original_path).await engine.get_upload(&original_path).await
} }