[feat] basic file uploader

This commit is contained in:
wfrsk 2022-09-14 17:40:00 +02:00
parent 49d9b7cb8a
commit 669899425d
No known key found for this signature in database
GPG Key ID: AA575FDE30E13CC6
7 changed files with 247 additions and 35 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
/images

32
Cargo.lock generated
View File

@ -911,6 +911,7 @@ dependencies = [
"rocket_codegen",
"rocket_http",
"serde",
"serde_json",
"state",
"tempfile",
"time",
@ -1020,6 +1021,12 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1_smol"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
[[package]]
name = "sha2"
version = "0.10.5"
@ -1342,6 +1349,29 @@ dependencies = [
"subtle",
]
[[package]]
name = "uuid"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
dependencies = [
"getrandom",
"rand",
"sha1_smol",
"uuid-macro-internal",
]
[[package]]
name = "uuid-macro-internal"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548f7181a5990efa50237abb7ebca410828b57a8955993334679f8b50b35c97d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "valuable"
version = "0.1.0"
@ -1359,6 +1389,8 @@ name = "violetta"
version = "0.1.0"
dependencies = [
"rocket",
"serde",
"uuid",
]
[[package]]

View File

@ -3,10 +3,16 @@ name = "violetta"
version = "0.1.0"
edition = "2021"
[toolchain]
channel = "nightly"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["secrets"] }
rocket = { version = "0.5.0-rc.2", features = ["serde_json", "json"] }
serde = "1.0.144"
[dependencies.uuid]
version = "1.1.2"
features = [
"v5", # Lets you generate random UUIDs
"fast-rng", # Use a faster (but still sufficiently random) RNG
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
]

View File

@ -1,10 +1,8 @@
[default]
address = "0.0.0.0"
port =
port = 8080
[default.tls]
certs = "path/to/cert-chain.pem"
key = "path/to/key.pem"
[default.limits]
form = "64 kB"
@ -22,5 +20,7 @@ mercy = 5
port = 3000
limits = { json = "10MiB" }
[release]
port = 8443
port = 8443
secret_key = "THIS IS A PLACEHOLDER, GENERATE YOUR OWN KEYS."

View File

@ -1,24 +1,208 @@
mod test;
use rocket::{
data::ToByteUnit,
fairing::AdHoc,
figment::{
providers::{Env, Format, Serialized, Toml},
Figment, Profile,
},
fs::{relative, NamedFile},
http::{ContentType, Status},
request::{FromRequest, Outcome},
serde::json::Json,
serde::*,
tokio::{fs::File, io::AsyncWriteExt},
*,
};
use uuid::Uuid;
use rocket::*;
#[derive(Default)]
struct RequiresAuthentication;
#[get("/hello/<name>")]
fn greet_user( name: String ) -> String {
format!("Hello, {name}!")
#[rocket::async_trait]
impl<'a> FromRequest<'a> for RequiresAuthentication {
type Error = UploadResponse;
async fn from_request(req: &'a Request<'_>) -> Outcome<Self, Self::Error> {
let expected_key = &req
.guard::<&State<UploaderConfiguration>>()
.await
.unwrap()
.inner()
.auth_key;
match expected_key {
Some(actual_key @ _) => {
if let Some(extern_key) = req.headers().get_one("Authorization") {
if actual_key == extern_key {
Outcome::Success(RequiresAuthentication::default())
} else {
Outcome::Failure((Status::Unauthorized, UploadResponse::error()))
}
} else {
Outcome::Failure((Status::Unauthorized, UploadResponse::error()))
}
}
None => Outcome::Success(RequiresAuthentication::default()),
}
}
}
#[derive(Deserialize)]
struct UploaderConfiguration {
pub max_size: u32,
pub auth_key: Option<String>,
pub allowed_extensions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct UploadResponse {
successful: bool,
file_id: Option<String>,
}
impl UploadResponse {
fn new(successful: bool, file_id: Option<String>) -> Self {
Self {
successful,
file_id,
}
}
fn error() -> Self {
Self {
successful: false,
file_id: None,
}
}
}
#[put("/", data = "<file>")]
async fn upload_media(
file: Data<'_>,
config: &State<UploaderConfiguration>,
_a: RequiresAuthentication,
content_type: &ContentType,
) -> std::io::Result<Json<UploadResponse>> {
match content_type.extension().map(|ext| ext.as_str()) {
ext @ Some("png") | ext @ Some("mp4") => {
let ext = ext.unwrap();
let current_dir = std::env::current_dir().unwrap();
let raw_data = file
.open(config.max_size.mebibytes())
.into_bytes()
.await?
.into_inner();
let file_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, &raw_data);
let mut file = File::create(current_dir.join(format!(
"{}/{}.{ext}",
relative!("images"),
file_id
)))
.await?;
file.write_all(&raw_data.as_slice()).await?;
Ok(Json(UploadResponse::new(true, Some(file_id.to_string()))))
}
_ => Ok(Json(UploadResponse::error())),
}
}
#[get("/<file_id>")]
async fn retrieve_media(file_id: &str, config: &State<UploaderConfiguration>) -> Option<NamedFile> {
let current_dir = std::env::current_dir().unwrap();
let ext: Option<String> = {
let mut corresponding_ext: Option<String> = None;
for ext in config.allowed_extensions.iter() {
let corresponding_path =
current_dir.join(format!("{}/{}.{ext}", relative!("images"), file_id));
if corresponding_path.is_file() {
corresponding_ext = Some(ext.clone());
}
}
corresponding_ext
};
if let Some(ext) = ext {
Some(
NamedFile::open(current_dir.join(format!("{}/{file_id}.{ext}", relative!("images"))))
.await
.unwrap(),
)
} else {
None
}
}
#[get("/")]
fn index( ) -> &'static str {
fn index() -> &'static str {
"USAGE
GET /hello/:name
simply, greets you.
GET /
what are you reading right now
PUT /
upload a file, send as raw body
GET /:id
gets the file <:id>
"
}
#[catch(401)]
fn unauthorized() -> Json<UploadResponse> {
Json(UploadResponse::error())
}
#[catch(500)]
fn internal_server_err() -> Json<UploadResponse> {
Json(UploadResponse::error())
}
#[catch(404)]
fn not_found() -> Json<UploadResponse> {
Json(UploadResponse::error())
}
#[launch]
pub fn rocket() -> _ {
rocket::build()
.mount(
"/", routes![ index, greet_user ]
)
rocket::custom(
Figment::from(rocket::Config::default())
.merge(Serialized::defaults(Config::default()))
.merge(Toml::file("violetta.toml").nested())
.merge(Env::prefixed("VIOLETTA_").global())
.select(Profile::from_env_or("VIOLETTA_PROFILE", "default")),
)
.mount("/", routes![index, upload_media, retrieve_media])
.register("/", catchers![not_found, unauthorized, internal_server_err])
.attach(AdHoc::config::<UploaderConfiguration>())
}
#[cfg(test)]
mod test {
use crate::*;
use rocket::local::blocking::Client;
macro_rules! client {
() => {
Client::tracked(rocket()).expect("valid rocket instance")
};
}
macro_rules! request {
( $client:ident, $method:ident, $path:expr ) => {
$client.$method($path)
};
}
#[test]
fn upload_file() {
let client = client!();
let response = request!(client, put, "/")
.header(ContentType::PNG)
.body(&[0u8; 1024])
.dispatch();
let response_body = response.into_json::<UploadResponse>().unwrap();
assert_eq!(response_body.successful, true);
assert!(response_body.file_id.is_some());
let response = request!(client, get, format!( "/{}", response_body.file_id.unwrap() )).dispatch();
assert_eq!(response.into_bytes().unwrap().as_slice(), [0u8; 1024]);
}
}

View File

@ -1,14 +0,0 @@
#[cfg(test)]
mod test {
use rocket::local::blocking::Client;
use crate::*;
#[test]
fn does_it_greet_us( ) {
let client = Client::tracked( rocket() )
.expect( "invalid rocket instance" );
let response = client.get( "/hello/World" ).dispatch( );
assert_eq!( response.into_string( ).unwrap( ), "Hello, World!" );
}
}

3
violetta.toml Normal file
View File

@ -0,0 +1,3 @@
[default]
max_size = 16 # in mebibytes [MiB]
allowed_extensions = [ "png", "jpg", "mp4" ]