Initial Commit

This commit is contained in:
Miroslav Vasilev 2023-10-25 13:50:36 +03:00
commit e748695f42
12 changed files with 2549 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1965
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "paste-eater"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
toml = "0.8.4"
serde = "1.0.189"
serde_derive = "1.0.189"
directories = "5.0.1"
clap = { version = "4.4.7", features = ["derive"] }
rocket = { version = "0.5.0-rc.3", features = ["tls", "json"] }
rand = "0.8.5"
chrono = "0.4.31"
lz4_flex = "0.11.1"

28
resources/Rocket.toml Normal file
View file

@ -0,0 +1,28 @@
[default]
address = "127.0.0.1"
port = 8000
workers = 16
max_blocking = 512
keep_alive = 5
ident = "Rocket"
ip_header = "X-Real-IP" # set to `false` to disable
log_level = "normal"
temp_dir = "/tmp"
cli_colors = true
secret_key = ""
[default.limits]
form = "64 kB"
json = "1 MiB"
msgpack = "2 MiB"
"file/jpg" = "5 MiB"
[default.tls]
certs = "path/to/cert-chain.pem"
key = "path/to/key.pem"
[default.shutdown]
ctrlc = true
signals = ["term", "hup"]
grace = 5
mercy = 5

25
src/args.rs Normal file
View file

@ -0,0 +1,25 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(next_line_help = true)]
pub struct Args {
/// Where to store the pastes. By default this is a "data" sub-directory within the system-specific configuration directory for the application ( see https://github.com/dirs-dev/directories-rs#basedirs" ).
#[arg(short, long)]
pub location: Option<String>,
/// Max size of a single paste
#[arg(short = 'f', long, default_value_t = 10000000)]
pub max_file_size: u64,
// /// Max size of all pastes existing on the server. Once this limit is reached, pastes will be deleted, starting with oldest first.
// #[arg(short = 's', long, default_value_t = 100000000)]
// pub max_storage_size: u64,
/// Constant size of new paste identifiers being generated. Changing this does not alter existing pastes.
#[arg(short, long, default_value_t = 12)]
pub name_size: usize,
#[arg(short, long, default_value_t = true)]
pub compress: bool
}

177
src/config.rs Normal file
View file

@ -0,0 +1,177 @@
use std::{path::{Path, PathBuf}, fs::File, io::Write};
use directories::ProjectDirs;
use serde_derive::{Deserialize, Serialize};
use crate::{args::Args, error::PasteEaterError};
const PASTE_EATER_CONFIG_FILE: &str = "config.toml";
const PASTE_EATER_QUALIFIER: &str = "dev.mvvasilev";
const PASTE_EATER_ORGANIZATION: &str = "mvvasilev";
const PASTE_EATER_APPLICATION: &str = "paste-eater";
type ConfigurationError = PasteEaterError;
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PasteEaterConfig {
/// Where to store the pastes
#[serde(default = "default_files_location")]
pub files_location: String,
/// Max size of a single paste
#[serde(default = "default_files_max_single_file_size_in_bytes")]
pub files_max_single_file_size_in_bytes: u64,
// /// Max size of all pastes existing on the server. Once this limit is reached, pastes will be deleted, starting with oldest first.
// #[serde(default = "default_files_max_total_file_size_in_bytes")]
// pub files_max_total_file_size_in_bytes: u64,
/// Constant size of new paste identifiers being generated. Changing this should not alter existing pastes.
#[serde(default = "default_name_size")]
pub name_size: usize,
#[serde(default = "default_compress")]
pub compress: bool
// TODO: Encrypted pastes?
}
fn default_files_location() -> String {
if let Some(config_dirs) = ProjectDirs::from(PASTE_EATER_QUALIFIER, PASTE_EATER_ORGANIZATION, PASTE_EATER_APPLICATION) {
let mut dir = config_dirs.config_dir().to_path_buf();
dir.push(Path::new("data"));
dir.display().to_string()
} else {
Path::new(".").to_path_buf().display().to_string()
}
}
fn default_files_max_single_file_size_in_bytes() -> u64 {
10_000_000
}
fn default_files_max_total_file_size_in_bytes() -> u64 {
100_000_000
}
fn default_name_size() -> usize {
12
}
fn default_compress() -> bool {
true
}
pub struct ConfigurationHandler {
config_path: PathBuf
}
impl ConfigurationHandler {
pub fn new() -> Result<Self, ConfigurationError> {
if let Some(config_dirs) = ProjectDirs::from(PASTE_EATER_QUALIFIER, PASTE_EATER_ORGANIZATION, PASTE_EATER_APPLICATION) {
ConfigurationHandler::new_with_path(config_dirs.config_dir())
} else {
Err(ConfigurationError::new("Unable to determine configuration path"))
}
}
pub fn new_with_args(args: &Args) -> Result<Self, ConfigurationError> {
let config = PasteEaterConfig {
files_location: args.location.clone().unwrap_or(default_files_location()),
files_max_single_file_size_in_bytes: args.max_file_size,
// files_max_total_file_size_in_bytes: args.max_storage_size,
name_size: args.name_size,
compress: args.compress
};
if let Some(config_dirs) = ProjectDirs::from(PASTE_EATER_QUALIFIER, PASTE_EATER_ORGANIZATION, PASTE_EATER_APPLICATION) {
ConfigurationHandler::new_with_defaults(config_dirs.config_dir(), &config)
} else {
Err(ConfigurationError::new("Unable to determine configuration path"))
}
}
pub fn new_with_path(path: &Path) -> Result<Self, ConfigurationError> {
ConfigurationHandler::new_with_defaults(path, &PasteEaterConfig {
files_location: default_files_location(),
files_max_single_file_size_in_bytes: default_files_max_single_file_size_in_bytes(),
// files_max_total_file_size_in_bytes: default_files_max_total_file_size_in_bytes(),
name_size: default_name_size(),
compress: default_compress()
})
}
pub fn new_with_defaults(path: &Path, default_config: &PasteEaterConfig) -> Result<Self, ConfigurationError> {
if path.extension().is_some() {
return Err(ConfigurationError::new(&format!("Provided configuration path '{}' is not a directory.", path.display())));
}
let mut pathbuf = path.to_path_buf();
match pathbuf.try_exists() {
Ok(exists) => {
if !exists {
match std::fs::create_dir_all(&pathbuf) {
Ok(_) => {},
Err(internal) => return Err(ConfigurationError::new_internal(&format!("Failed to create configuration directory '{}'.", path.display()), Box::new(internal))),
}
}
},
Err(err) => return Err(ConfigurationError::new_internal(&format!("Failed to check if configuration directory '{}' exists.", path.display()), Box::new(err))),
}
pathbuf.push(Path::new(PASTE_EATER_CONFIG_FILE));
if !pathbuf.exists() {
match File::create(&pathbuf) {
Ok(mut f) => {
let config = PasteEaterConfig {
files_location: if default_config.files_location.is_empty() { default_files_location() } else { default_config.files_location.clone() },
files_max_single_file_size_in_bytes: default_config.files_max_single_file_size_in_bytes,
// files_max_total_file_size_in_bytes: default_config.files_max_total_file_size_in_bytes,
name_size: default_config.name_size,
compress: default_config.compress
};
let serialized_default_config = toml::to_string_pretty(&config);
let write_default_config = match serialized_default_config {
Ok(ser) => f.write_all(ser.as_bytes()),
Err(err) => return Err(ConfigurationError::new_internal("Failed to create default configuration.", Box::new(err))),
};
match write_default_config {
Ok(_) => {},
Err(err) => return Err(ConfigurationError::new_internal("Failed to create default configuration.", Box::new(err))),
}
},
Err(err) => return Err(ConfigurationError::new_internal(&format!("Failed to create configuration file '{}'", path.display()), Box::new(err))),
}
}
Ok(Self {
config_path: pathbuf
})
}
pub fn fetch_config(&self) -> Result<PasteEaterConfig, ConfigurationError> {
let config_as_string = std::fs::read_to_string(&self.config_path);
let config: PasteEaterConfig = match config_as_string {
Ok(s) => {
match toml::from_str(&s) {
Ok(config) => config,
Err(err) => return Err(ConfigurationError::new_internal(&format!("Failed to parse configuration file '{}'", self.config_path.display()), Box::new(err))),
}
},
Err(err) => return Err(ConfigurationError::new_internal(&format!("Failed to read configuration file '{}'", self.config_path.display()), Box::new(err))),
};
Ok(config)
}
}

37
src/error.rs Normal file
View file

@ -0,0 +1,37 @@
use std::{error::Error, fmt::Display};
#[derive(Debug)]
pub struct PasteEaterError {
internal_error: Option<Box<dyn Error>>,
message: String
}
impl PasteEaterError {
pub fn new(message: &str) -> Self {
Self {
message: message.to_string(),
internal_error: None
}
}
pub fn new_internal(message: &str, internal: Box<dyn Error>) -> Self {
Self {
message: message.to_string(),
internal_error: Some(internal)
}
}
}
impl Display for PasteEaterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Paste eater error: {}, internal: {}",
self.message,
self.internal_error.as_ref().map_or("".to_string(), |e| format!("{}", e))
)
}
}
impl Error for PasteEaterError {
}

12
src/main.rs Normal file
View file

@ -0,0 +1,12 @@
mod server;
mod config;
mod args;
mod paste;
mod error;
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
server::app::start_paste_eater().await?;
Ok(())
}

221
src/paste.rs Normal file
View file

@ -0,0 +1,221 @@
use std::{path::{Path, PathBuf}, fs::File};
use chrono::{DateTime, Local};
use lz4_flex::{compress_prepend_size, decompress_size_prepended};
use rand::{Rng, distributions::Alphanumeric};
use serde_derive::{Serialize, Deserialize};
use crate::{config::ConfigurationHandler, error::PasteEaterError};
type PasteError = PasteEaterError;
type PasteUID = String;
#[repr(u8)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PasteLanguage {
None = 0u8,
CSharp = 1u8,
Java = 2u8,
CPlusPlus = 3u8,
C = 4u8,
Python = 5u8,
Rust = 6u8,
Go = 7u8,
}
fn convert_u8_to_language(val: u8) -> PasteLanguage {
match val {
0u8 => PasteLanguage::None,
1u8 => PasteLanguage::CSharp,
2u8 => PasteLanguage::Java,
3u8 => PasteLanguage::CPlusPlus,
4u8 => PasteLanguage::C,
5u8 => PasteLanguage::Python,
6u8 => PasteLanguage::Rust,
7u8 => PasteLanguage::Go,
_ => PasteLanguage::None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasteOutput {
pub encrypted: bool,
pub created: String,
pub last_accessed: String,
pub language: PasteLanguage,
pub data: String
}
pub struct PasteHandler {
config_handler: ConfigurationHandler,
// storage_size_cache: u64, TODO: Optimize storage size tracking
}
impl PasteHandler {
pub fn new(config_handler: ConfigurationHandler) -> Self {
Self {
config_handler,
// storage_size_cache: 0
}
}
pub fn create_new_paste(&self, encrypt: bool, language: &PasteLanguage, paste_data: &String) -> Result<PasteUID, PasteError> {
let config = self.config_handler.fetch_config()?;
let data_path = Path::new(&config.files_location);
match data_path.try_exists() {
Ok(exists) => {
if !exists {
match std::fs::create_dir_all(data_path) {
Ok(_) => {},
Err(internal) => return Err(PasteError::new_internal(&format!("Failed to create data directory '{}'.", data_path.display()), Box::new(internal))),
}
}
},
Err(err) => return Err(PasteError::new_internal(&format!("Failed to check if data directory '{}' exists.", data_path.display()), Box::new(err))),
}
// TODO: Encrypt (?)
if paste_data.len() as u64 > config.files_max_single_file_size_in_bytes {
return Err(PasteError::new("Uploaded paste is larger than maximum allowed size."));
}
// TODO: Enforce max size for single file and max total sizes. Note: scrapped for now.
// if self.determine_current_storage_size() + paste_data.len() as u64 > config.files_max_total_file_size_in_bytes {
// if let Err(e) = std::fs::remove_file(self.find_oldest_paste_larger_than(paste_data.len() as u64)) {
// return Err(PasteError::new_internal("Unable to save paste, overloads capacity and no additional storage could be acquired.", Box::new(e)));
// }
// }
let (file_path, uid) = self.create_new_paste_file(Path::new(&config.files_location), config.name_size)?;
// paste file format:
// 0: is encrypted | is compressed | unused | unused | unused | unused | unused | unused
// 1: language byte
// 2-end: data bytes
let mut file_data: Vec<u8> = Vec::new();
let mut flags = 0b0000_0000;
if encrypt {
flags |= 0b1000_0000;
}
if config.compress {
flags |= 0b0100_0000;
}
file_data.push(flags);
file_data.push(language.to_owned() as u8);
if config.compress {
file_data.extend(compress_prepend_size(paste_data.as_bytes()));
}
match std::fs::write(&file_path, file_data) {
Ok(_) => Ok(uid),
Err(e) => Err(PasteError::new_internal(&format!("Failed to write paste file '{}'.", file_path.display()), Box::new(e))),
}
}
// fn determine_current_storage_size(&self) -> u64 {
// 0
// }
// fn find_oldest_paste_larger_than(&self, size: u64) -> PathBuf {
// Path::new(".").to_path_buf()
// }
fn create_new_paste_file(&self, directory: &Path, name_size: usize) -> Result<(PathBuf, PasteUID), PasteError> {
let mut uid: String;
let mut file_path;
loop {
uid = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(name_size)
.map(char::from)
.collect();
file_path = directory.to_path_buf();
file_path.push(Path::new(&format!("{}.paste", uid)));
if !file_path.exists() {
break;
}
}
match File::create(&file_path) {
Ok(_) => Ok((file_path, uid)),
Err(e) => Err(PasteError::new_internal(&format!("Failed to create paste file '{}'.", file_path.display()), Box::new(e))),
}
}
pub fn fetch_raw_paste(&self, uid: PasteUID) -> Result<PasteOutput, PasteError> {
let config = self.config_handler.fetch_config()?;
let mut file_path = Path::new(&config.files_location).to_path_buf();
file_path.push(Path::new(&format!("{}.paste", uid)));
if !file_path.exists() {
return Err(PasteError::new(&format!("Requested paste '{}' does not exist.", uid)));
}
let file_bytes = match std::fs::read(&file_path) {
Ok(bytes) => bytes,
Err(e) => return Err(PasteError::new_internal(&format!("Failed to read paste '{}'.", uid), Box::new(e))),
};
let flags = file_bytes[0];
let encrypted = 0b1000_0000 & flags != 0;
let compressed = 0b0100_0000 & flags != 0;
let language = convert_u8_to_language(file_bytes[1]);
let bytes = if compressed {
match decompress_size_prepended(&file_bytes[2..]) {
Ok(d) => d,
Err(e) => return Err(PasteError::new_internal(&format!("Failed to decompress paste '{}'.", uid), Box::new(e))),
}
} else {
file_bytes[2..].to_vec()
};
let data = match String::from_utf8(bytes) {
Ok(d) => d,
Err(e) => return Err(PasteError::new_internal(&format!("Failed to parse paste '{}'.", uid), Box::new(e))),
};
let mut paste = PasteOutput {
encrypted,
last_accessed: DateTime::UNIX_EPOCH.to_rfc2822(),
created: DateTime::UNIX_EPOCH.to_rfc2822(),
language,
data
};
if let Ok(metadata) = std::fs::metadata(file_path) {
paste.created = match metadata.created() {
Ok(created) => DateTime::<Local>::from(created).to_rfc2822(),
Err(_) => paste.created,
};
paste.last_accessed = match metadata.accessed() {
Ok(accessed) => DateTime::<Local>::from(accessed).to_rfc2822(),
Err(_) => paste.last_accessed,
};
}
Ok(paste)
}
}

29
src/server/app.rs Normal file
View file

@ -0,0 +1,29 @@
use clap::Parser;
use rocket::routes;
use crate::{server::endpoints, args::Args, config::ConfigurationHandler, paste::PasteHandler, error::PasteEaterError};
pub async fn start_paste_eater() -> Result<(), rocket::Error> {
let paste_handler = match create_paste_handler() {
Ok(ph) => ph,
Err(e) => {
panic!("{}", e);
},
};
let _rocket = rocket::build()
.manage(paste_handler)
.mount("/api", routes![endpoints::create_paste, endpoints::get_paste])
.launch()
.await?;
Ok(())
}
fn create_paste_handler() -> Result<PasteHandler, PasteEaterError> {
let args = Args::parse();
let config_handler = ConfigurationHandler::new_with_args(&args)?;
Ok(PasteHandler::new(config_handler))
}

33
src/server/endpoints.rs Normal file
View file

@ -0,0 +1,33 @@
use rocket::{post, get, response::status::Custom, http::Status, State, serde::json::Json};
use serde_derive::{Serialize, Deserialize};
use crate::paste::{PasteHandler, PasteOutput, PasteLanguage};
#[derive(Serialize, Deserialize)]
pub struct PasteInput {
encrypted: bool,
language: PasteLanguage,
data: String
}
#[post("/paste", data = "<paste>")]
pub fn create_paste(paste: Json<PasteInput>, paste_handler: &State<PasteHandler>) -> Custom<String> {
match paste_handler.create_new_paste(paste.encrypted, &paste.language, &paste.data) {
Ok(uid) => Custom(Status::Ok, uid),
Err(e) => Custom(Status::InternalServerError, format!("{}", e)),
}
}
#[derive(Serialize, Deserialize)]
pub struct PasteResponse {
pub paste: Option<PasteOutput>,
pub error: Option<String>
}
#[get("/paste/<uid>")]
pub fn get_paste(uid: String, paste_handler: &State<PasteHandler>) -> Custom<Json<PasteResponse>> {
match paste_handler.fetch_raw_paste(uid) {
Ok(paste) => Custom(Status::Ok, Json(PasteResponse { paste: Some(paste), error: None })),
Err(e) => Custom(Status::InternalServerError, Json(PasteResponse { paste: None, error: Some(format!("{}", e)) })),
}
}

3
src/server/mod.rs Normal file
View file

@ -0,0 +1,3 @@
mod endpoints;
pub mod app;