diff --git a/src/database.rs b/src/database.rs index 46991d5..4194121 100644 --- a/src/database.rs +++ b/src/database.rs @@ -16,7 +16,7 @@ use serde_with::{serde_as, DisplayFromStr}; const BINCODE_CFG: Configuration = bincode::config::standard(); #[derive(Debug, Clone, Decode, Encode)] -pub struct Database { +pub struct Mochibase { path: PathBuf, /// Every hash in the database along with the [`Mmid`]s associated with them @@ -28,7 +28,7 @@ pub struct Database { entries: HashMap, } -impl Database { +impl Mochibase { pub fn new>(path: &P) -> Result { let output = Self { path: path.as_ref().to_path_buf(), @@ -225,7 +225,7 @@ impl MochiFile { /// Clean the database. Removes files which are past their expiry /// [`chrono::DateTime`]. Also removes files which no longer exist on the disk. -fn clean_database(db: &Arc>, file_path: &Path) { +fn clean_database(db: &Arc>, file_path: &Path) { let mut database = db.write().unwrap(); // Add expired entries to the removal list @@ -256,7 +256,7 @@ fn clean_database(db: &Arc>, file_path: &Path) { } } - info!("Cleaned database. Removed {removed_entries} expired entries. Removed {removed_files} no longer referenced files."); + info!("Cleaned database.\n\t| Removed {removed_entries} expired entries.\n\t| Removed {removed_files} no longer referenced files."); if let Err(e) = database.save() { error!("Failed to save database: {e}") @@ -266,7 +266,7 @@ fn clean_database(db: &Arc>, file_path: &Path) { /// A loop to clean the database periodically. pub async fn clean_loop( - db: Arc>, + db: Arc>, file_path: PathBuf, mut shutdown_signal: Receiver<()>, interval: TimeDelta, diff --git a/src/endpoints.rs b/src/endpoints.rs index 648f399..5268086 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -11,7 +11,7 @@ use rocket::{ use serde::Serialize; use crate::{ - database::{Database, Mmid, MochiFile}, + database::{Mochibase, Mmid, MochiFile}, settings::Settings, }; @@ -35,7 +35,7 @@ pub fn server_info(settings: &State) -> Json { /// Get information about a file #[get("/info/")] pub async fn file_info( - db: &State>>, + db: &State>>, mmid: &str, ) -> Option> { let mmid: Mmid = mmid.try_into().ok()?; @@ -55,7 +55,7 @@ pub struct ServerInfo { } #[get("/f/")] -pub async fn lookup_mmid(db: &State>>, mmid: &str) -> Option { +pub async fn lookup_mmid(db: &State>>, mmid: &str) -> Option { let mmid: Mmid = mmid.try_into().ok()?; let entry = db.read().unwrap().get(&mmid).cloned()?; @@ -67,7 +67,7 @@ pub async fn lookup_mmid(db: &State>>, mmid: &str) -> Optio #[get("/f/?noredir")] pub async fn lookup_mmid_noredir( - db: &State>>, + db: &State>>, settings: &State, mmid: &str, ) -> Option<(ContentType, File)> { @@ -86,7 +86,7 @@ pub async fn lookup_mmid_noredir( #[get("/f//")] pub async fn lookup_mmid_name( - db: &State>>, + db: &State>>, settings: &State, mmid: &str, name: &str, diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a81ac69 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,174 @@ +pub mod database; +pub mod endpoints; +pub mod settings; +pub mod strings; +pub mod utils; +pub mod pages; +pub mod resources; + +use std::sync::{Arc, RwLock}; + +use chrono::{DateTime, Utc}; +use crate::database::{Mmid, MochiFile, Mochibase}; +use maud::{html, Markup, PreEscaped}; +use crate::pages::{footer, head}; +use rocket::{ + data::ToByteUnit, form::Form, fs::TempFile, get, post, serde::{json::Json, Serialize}, FromForm, State +}; +use crate::settings::Settings; +use crate::strings::{parse_time_string, to_pretty_time}; +use crate::utils::hash_file; +use uuid::Uuid; + +#[get("/")] +pub fn home(settings: &State) -> Markup { + html! { + (head("Confetti-Box")) + script src="/resources/request.js" { } + + center { + h1 { "Confetti-Box 🎉" } + h2 { "Files up to " (settings.max_filesize.bytes()) " in size are allowed!" } + hr; + button.main_file_upload #fileButton onclick="document.getElementById('fileInput').click()" { + h4 { "Upload File(s)" } + p { "Click, Paste, or Drag and Drop" } + } + h3 { "Expire after:" } + div id="durationBox" { + @for d in &settings.duration.allowed { + button.button.{@if settings.duration.default == *d { "selected" }} + data-duration-seconds=(d.num_seconds()) + { + (PreEscaped(to_pretty_time(d.num_seconds() as u32))) + } + } + } + form #uploadForm { + // It's stupid how these can't be styled so they're just hidden here... + input #fileDuration type="text" name="duration" minlength="2" + maxlength="7" value=(settings.duration.default.num_seconds().to_string() + "s") style="display:none;"; + input #fileInput type="file" name="fileUpload" multiple + onchange="formSubmit(this.parentNode)" data-max-filesize=(settings.max_filesize) style="display:none;"; + } + hr; + + h3 { "Uploaded Files" } + div #uploadedFilesDisplay { + + } + + hr; + (footer()) + } + } +} + +#[derive(Debug, FromForm)] +pub struct Upload<'r> { + #[field(name = "duration")] + expire_time: String, + + #[field(name = "fileUpload")] + file: TempFile<'r>, +} + +/// Handle a file upload and store it +#[post("/upload", data = "")] +pub async fn handle_upload( + mut file_data: Form>, + db: &State>>, + settings: &State, +) -> Result, std::io::Error> { + let current = Utc::now(); + // Ensure the expiry time is valid, if not return an error + let expire_time = if let Ok(t) = parse_time_string(&file_data.expire_time) { + if settings.duration.restrict_to_allowed && !settings.duration.allowed.contains(&t) { + return Ok(Json(ClientResponse::failure("Duration not allowed"))); + } + + if t > settings.duration.maximum { + return Ok(Json(ClientResponse::failure("Duration larger than max"))); + } + + t + } else { + return Ok(Json(ClientResponse::failure("Duration invalid"))); + }; + + let raw_name = file_data + .file + .raw_name() + .unwrap() + .dangerous_unsafe_unsanitized_raw() + .as_str() + .to_string(); + + // Get temp path for the file + let temp_filename = settings.temp_dir.join(Uuid::new_v4().to_string()); + file_data.file.persist_to(&temp_filename).await?; + + // Get hash and random identifier and expiry + let file_mmid = Mmid::new(); + let file_hash = hash_file(&temp_filename).await?; + let expiry = current + expire_time; + + // Process filetype + let file_type = file_format::FileFormat::from_file(&temp_filename)?; + + let constructed_file = MochiFile::new( + file_mmid.clone(), + raw_name, + file_type.media_type().to_string(), + file_hash, + current, + expiry + ); + + // If the hash does not exist in the database, + // move the file to the backend, else, delete it + if db.read().unwrap().get_hash(&file_hash).is_none() { + std::fs::rename(temp_filename, settings.file_dir.join(file_hash.to_string()))?; + } else { + std::fs::remove_file(temp_filename)?; + } + + db.write().unwrap().insert(&file_mmid, constructed_file.clone()); + + Ok(Json(ClientResponse { + status: true, + name: constructed_file.name().clone(), + mmid: Some(constructed_file.mmid().clone()), + hash: constructed_file.hash().to_string(), + expires: Some(constructed_file.expiry()), + ..Default::default() + })) +} + +/// A response to the client from the server +#[derive(Serialize, Default, Debug)] +pub struct ClientResponse { + /// Success or failure + pub status: bool, + + pub response: &'static str, + + #[serde(skip_serializing_if = "str::is_empty")] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mmid: Option, + #[serde(skip_serializing_if = "str::is_empty")] + pub hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires: Option>, +} + +impl ClientResponse { + fn failure(response: &'static str) -> Self { + Self { + status: false, + response, + ..Default::default() + } + } +} diff --git a/src/main.rs b/src/main.rs index 4d0114b..dcf2aff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,182 +1,9 @@ -mod database; -mod endpoints; -mod settings; -mod strings; -mod utils; -mod pages; +use std::{fs, sync::{Arc, RwLock}}; -use std::{ - fs, - sync::{Arc, RwLock}, -}; - -use chrono::{DateTime, TimeDelta, Utc}; -use database::{clean_loop, Database, Mmid, MochiFile}; -use endpoints::{file_info, lookup_mmid, lookup_mmid_name, lookup_mmid_noredir, server_info}; +use chrono::TimeDelta; +use confetti_box::{database::{clean_loop, Mochibase}, endpoints, pages, resources, settings::Settings}; use log::info; -use maud::{html, Markup, PreEscaped}; -use pages::{about, api_info, favicon, fira_code, footer, form_handler_js, head, roboto_flex, stylesheet}; -use rocket::{ - data::{Limits, ToByteUnit}, form::Form, fs::TempFile, get, post, routes, serde::{json::Json, Serialize}, tokio, Config, FromForm, State -}; -use settings::Settings; -use strings::{parse_time_string, to_pretty_time}; -use utils::hash_file; -use uuid::Uuid; - -#[get("/")] -fn home(settings: &State) -> Markup { - html! { - (head("Confetti-Box")) - script src="/resources/request.js" { } - - center { - h1 { "Confetti-Box 🎉" } - h2 { "Files up to " (settings.max_filesize.bytes()) " in size are allowed!" } - hr; - button.main_file_upload #fileButton onclick="document.getElementById('fileInput').click()" { - h4 { "Upload File(s)" } - p { "Click, Paste, or Drag and Drop" } - } - h3 { "Expire after:" } - div id="durationBox" { - @for d in &settings.duration.allowed { - button.button.{@if settings.duration.default == *d { "selected" }} - data-duration-seconds=(d.num_seconds()) - { - (PreEscaped(to_pretty_time(d.num_seconds() as u32))) - } - } - } - form #uploadForm { - // It's stupid how these can't be styled so they're just hidden here... - input #fileDuration type="text" name="duration" minlength="2" - maxlength="7" value=(settings.duration.default.num_seconds().to_string() + "s") style="display:none;"; - input #fileInput type="file" name="fileUpload" multiple - onchange="formSubmit(this.parentNode)" data-max-filesize=(settings.max_filesize) style="display:none;"; - } - hr; - - h3 { "Uploaded Files" } - div #uploadedFilesDisplay { - - } - - hr; - (footer()) - } - } -} - -#[derive(Debug, FromForm)] -struct Upload<'r> { - #[field(name = "duration")] - expire_time: String, - - #[field(name = "fileUpload")] - file: TempFile<'r>, -} - -/// Handle a file upload and store it -#[post("/upload", data = "")] -async fn handle_upload( - mut file_data: Form>, - db: &State>>, - settings: &State, -) -> Result, std::io::Error> { - let current = Utc::now(); - // Ensure the expiry time is valid, if not return an error - let expire_time = if let Ok(t) = parse_time_string(&file_data.expire_time) { - if settings.duration.restrict_to_allowed && !settings.duration.allowed.contains(&t) { - return Ok(Json(ClientResponse::failure("Duration not allowed"))); - } - - if t > settings.duration.maximum { - return Ok(Json(ClientResponse::failure("Duration larger than max"))); - } - - t - } else { - return Ok(Json(ClientResponse::failure("Duration invalid"))); - }; - - let raw_name = file_data - .file - .raw_name() - .unwrap() - .dangerous_unsafe_unsanitized_raw() - .as_str() - .to_string(); - - // Get temp path for the file - let temp_filename = settings.temp_dir.join(Uuid::new_v4().to_string()); - file_data.file.persist_to(&temp_filename).await?; - - // Get hash and random identifier and expiry - let file_mmid = Mmid::new(); - let file_hash = hash_file(&temp_filename).await?; - let expiry = current + expire_time; - - // Process filetype - let file_type = file_format::FileFormat::from_file(&temp_filename)?; - - let constructed_file = MochiFile::new( - file_mmid.clone(), - raw_name, - file_type.media_type().to_string(), - file_hash, - current, - expiry - ); - - // If the hash does not exist in the database, - // move the file to the backend, else, delete it - if db.read().unwrap().get_hash(&file_hash).is_none() { - std::fs::rename(temp_filename, settings.file_dir.join(file_hash.to_string()))?; - } else { - std::fs::remove_file(temp_filename)?; - } - - db.write().unwrap().insert(&file_mmid, constructed_file.clone()); - - Ok(Json(ClientResponse { - status: true, - name: constructed_file.name().clone(), - mmid: Some(constructed_file.mmid().clone()), - hash: constructed_file.hash().to_string(), - expires: Some(constructed_file.expiry()), - ..Default::default() - })) -} - -/// A response to the client from the server -#[derive(Serialize, Default, Debug)] -#[serde(crate = "rocket::serde")] -struct ClientResponse { - /// Success or failure - pub status: bool, - - pub response: &'static str, - - #[serde(skip_serializing_if = "str::is_empty")] - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub mmid: Option, - #[serde(skip_serializing_if = "str::is_empty")] - pub hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires: Option>, -} - -impl ClientResponse { - fn failure(response: &'static str) -> Self { - Self { - status: false, - response, - ..Default::default() - } - } -} +use rocket::{data::ToByteUnit as _, routes, tokio}; #[rocket::main] async fn main() { @@ -192,17 +19,17 @@ async fn main() { } // Set rocket configuration settings - let rocket_config = Config { + let rocket_config = rocket::Config { address: config.server.address.parse().expect("IP address invalid"), port: config.server.port, temp_dir: config.temp_dir.clone().into(), - limits: Limits::default() + limits: rocket::data::Limits::default() .limit("data-form", config.max_filesize.bytes()) .limit("file", config.max_filesize.bytes()), ..Default::default() }; - let database = Arc::new(RwLock::new(Database::open_or_new(&config.database_path).expect("Failed to open or create database"))); + let database = Arc::new(RwLock::new(Mochibase::open_or_new(&config.database_path).expect("Failed to open or create database"))); let local_db = database.clone(); // Start monitoring thread, cleaning the database every 2 minutes @@ -217,25 +44,24 @@ async fn main() { .mount( config.server.root_path.clone() + "/", routes![ - home, - api_info, - about, - favicon, - form_handler_js, - stylesheet, - fira_code, - roboto_flex, + confetti_box::home, + pages::api_info, + pages::about, + resources::favicon, + resources::form_handler_js, + resources::stylesheet, + resources::font_static, ], ) .mount( config.server.root_path.clone() + "/", routes![ - handle_upload, - server_info, - file_info, - lookup_mmid, - lookup_mmid_noredir, - lookup_mmid_name, + confetti_box::handle_upload, + endpoints::server_info, + endpoints::file_info, + endpoints::lookup_mmid, + endpoints::lookup_mmid_noredir, + endpoints::lookup_mmid_name, ], ) .manage(database) diff --git a/src/pages.rs b/src/pages.rs index 767f251..f37a556 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -26,32 +26,6 @@ pub fn footer() -> Markup { } } -/// Stylesheet -#[get("/resources/main.css")] -pub fn stylesheet() -> RawCss<&'static str> { - RawCss(include_str!("../web/main.css")) -} - -/// Upload handler javascript -#[get("/resources/request.js")] -pub fn form_handler_js() -> RawJavaScript<&'static str> { - RawJavaScript(include_str!("../web/request.js")) -} - -#[get("/resources/favicon.svg")] -pub fn favicon() -> (ContentType, &'static str) { - (ContentType::SVG, include_str!("../web/favicon.svg")) -} - -#[get("/resources/Roboto.woff2")] -pub fn roboto_flex() -> (ContentType, &'static [u8]) { - (ContentType::WOFF2, include_bytes!("../web/fonts/roboto.woff2")) -} -#[get("/resources/FiraCode.woff2")] -pub fn fira_code() -> (ContentType, &'static [u8]) { - (ContentType::WOFF2, include_bytes!("../web/fonts/fira-code.woff2")) -} - #[get("/api")] pub fn api_info(settings: &State) -> Markup { let domain = &settings.server.domain; @@ -156,7 +130,7 @@ pub fn api_info(settings: &State) -> Markup { } #[get("/about")] -pub fn about(settings: &State) -> Markup { +pub fn about() -> Markup { html! { (head("Confetti-Box | About")) diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..05d094b --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,27 @@ +use rocket::{get, http::ContentType, response::content::{RawCss, RawJavaScript}}; + +#[get("/resources/fonts/")] +pub fn font_static(font: &str) -> Option<(ContentType, &'static [u8])> { + match font { + "Roboto.woff2" => Some((ContentType::WOFF2, include_bytes!("../web/fonts/roboto.woff2"))), + "FiraCode.woff2" => Some((ContentType::WOFF2, include_bytes!("../web/fonts/fira-code.woff2"))), + _ => None + } +} + +/// Stylesheet +#[get("/resources/main.css")] +pub fn stylesheet() -> RawCss<&'static str> { + RawCss(include_str!("../web/main.css")) +} + +/// Upload handler javascript +#[get("/resources/request.js")] +pub fn form_handler_js() -> RawJavaScript<&'static str> { + RawJavaScript(include_str!("../web/request.js")) +} + +#[get("/resources/favicon.svg")] +pub fn favicon() -> (ContentType, &'static str) { + (ContentType::SVG, include_str!("../web/favicon.svg")) +} diff --git a/web/main.css b/web/main.css index 7233d63..4657101 100644 --- a/web/main.css +++ b/web/main.css @@ -1,20 +1,20 @@ @font-face { - font-family: "Roboto"; - src: - /*local("Roboto"),*/ - url("/resources/Roboto.woff2"); + font-family: "Roboto"; + src: + local("Roboto"), + url("/resources/fonts/Roboto.woff2"); } @font-face { - font-family: "Fira Code"; - src: - /*local("Fira Code"),*/ - url("/resources/FiraCode.woff2"); + font-family: "Fira Code"; + src: + local("Fira Code"), + url("/resources/fonts/FiraCode.woff2"); } body { font-family: "Roboto", sans-serif; - font-size: 14pt; + font-size: 12pt; font-optical-sizing: auto; }