mirror of
https://github.com/Dangoware/confetti-box.git
synced 2025-04-19 23:32:58 -05:00
297 lines
8.8 KiB
Rust
297 lines
8.8 KiB
Rust
mod database;
|
|
mod endpoints;
|
|
mod settings;
|
|
mod strings;
|
|
mod utils;
|
|
|
|
use std::{
|
|
fs,
|
|
sync::{Arc, RwLock},
|
|
};
|
|
|
|
use chrono::{DateTime, TimeDelta, Utc};
|
|
use database::{clean_loop, Database, Mmid, MochiFile};
|
|
use endpoints::{lookup_mmid, lookup_mmid_name, server_info};
|
|
use log::info;
|
|
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
|
use rocket::{
|
|
data::{Limits, ToByteUnit},
|
|
form::Form,
|
|
fs::{FileServer, Options, TempFile},
|
|
get,
|
|
http::ContentType,
|
|
post,
|
|
response::content::{RawCss, RawJavaScript},
|
|
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;
|
|
|
|
fn head(page_title: &str) -> Markup {
|
|
html! {
|
|
(DOCTYPE)
|
|
meta charset="UTF-8";
|
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
|
title { (page_title) }
|
|
// Javascript stuff for client side handling
|
|
script src="./request.js" { }
|
|
link rel="icon" type="image/svg+xml" href="favicon.svg";
|
|
link rel="stylesheet" href="./main.css";
|
|
}
|
|
}
|
|
|
|
/// Stylesheet
|
|
#[get("/main.css")]
|
|
fn stylesheet() -> RawCss<&'static str> {
|
|
RawCss(include_str!("../web/main.css"))
|
|
}
|
|
|
|
/// Upload handler javascript
|
|
#[get("/request.js")]
|
|
fn form_handler_js() -> RawJavaScript<&'static str> {
|
|
RawJavaScript(include_str!("../web/request.js"))
|
|
}
|
|
|
|
#[get("/favicon.svg")]
|
|
fn favicon() -> (ContentType, &'static str) {
|
|
(ContentType::SVG, include_str!("../web/favicon.svg"))
|
|
}
|
|
|
|
#[get("/")]
|
|
fn home(settings: &State<Settings>) -> Markup {
|
|
html! {
|
|
(head("Confetti-Box"))
|
|
|
|
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 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 #fileInput type="file" name="fileUpload" multiple
|
|
onchange="formSubmit(this.parentNode)" data-max-filesize=(settings.max_filesize) style="display:none;";
|
|
input #fileDuration type="text" name="duration" minlength="2"
|
|
maxlength="7" value=(settings.duration.default.num_seconds().to_string() + "s") style="display:none;";
|
|
}
|
|
hr;
|
|
|
|
h3 { "Uploaded Files" }
|
|
div #uploadedFilesDisplay {
|
|
|
|
}
|
|
|
|
hr;
|
|
footer {
|
|
p {a href="https://github.com/G2-Games/confetti-box" {"Source"}}
|
|
p {a href="https://g2games.dev/" {"My Website"}}
|
|
p {a href="api" {"API Info"}}
|
|
p {a href="#" {"Go"}}
|
|
p {a href="#" {"Here"}}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 = "<file_data>")]
|
|
async fn handle_upload(
|
|
mut file_data: Form<Upload<'_>>,
|
|
db: &State<Arc<RwLock<Database>>>,
|
|
settings: &State<Settings>,
|
|
) -> Result<Json<ClientResponse>, std::io::Error> {
|
|
// 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
|
|
let file_mmid = Mmid::new();
|
|
let file_hash = hash_file(&temp_filename).await?;
|
|
|
|
// Process filetype
|
|
let file_type = file_format::FileFormat::from_file(&temp_filename)?;
|
|
|
|
let constructed_file =
|
|
MochiFile::new_with_expiry(
|
|
file_mmid.clone(),
|
|
raw_name,
|
|
file_type.extension(),
|
|
file_hash,
|
|
expire_time
|
|
);
|
|
|
|
// Move it to the new proper place
|
|
std::fs::rename(
|
|
temp_filename,
|
|
settings.file_dir.join(file_hash.to_string())
|
|
)?;
|
|
|
|
db.write()
|
|
.unwrap()
|
|
.insert(constructed_file.clone());
|
|
db.write().unwrap().save();
|
|
|
|
Ok(Json(ClientResponse {
|
|
status: true,
|
|
name: constructed_file.name().clone(),
|
|
mmid: Some(file_mmid),
|
|
hash: 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<Mmid>,
|
|
#[serde(skip_serializing_if = "str::is_empty")]
|
|
pub hash: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl ClientResponse {
|
|
fn failure(response: &'static str) -> Self {
|
|
Self {
|
|
status: false,
|
|
response,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[rocket::main]
|
|
async fn main() {
|
|
// Get or create config file
|
|
let config = Settings::open(&"./settings.toml").expect("Could not open settings file");
|
|
|
|
if !config.temp_dir.try_exists().is_ok_and(|e| e) {
|
|
fs::create_dir_all(config.temp_dir.clone()).expect("Failed to create temp directory");
|
|
}
|
|
|
|
if !config.file_dir.try_exists().is_ok_and(|e| e) {
|
|
fs::create_dir_all(config.file_dir.clone()).expect("Failed to create file directory");
|
|
}
|
|
|
|
// Set rocket configuration settings
|
|
let rocket_config = Config {
|
|
address: config.server.address.parse().expect("IP address invalid"),
|
|
port: config.server.port,
|
|
temp_dir: config.temp_dir.clone().into(),
|
|
limits: 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(&config.database_path)));
|
|
let local_db = database.clone();
|
|
|
|
// Start monitoring thread, cleaning the database every 2 minutes
|
|
let (shutdown, rx) = tokio::sync::mpsc::channel(1);
|
|
tokio::spawn({
|
|
let cleaner_db = database.clone();
|
|
let file_path = config.file_dir.clone();
|
|
async move { clean_loop(cleaner_db, file_path, rx, TimeDelta::minutes(2)).await }
|
|
});
|
|
|
|
let rocket = rocket::build()
|
|
.mount(
|
|
config.server.root_path.clone() + "/",
|
|
routes![
|
|
home,
|
|
handle_upload,
|
|
form_handler_js,
|
|
stylesheet,
|
|
server_info,
|
|
favicon,
|
|
lookup_mmid,
|
|
lookup_mmid_name,
|
|
],
|
|
)
|
|
.mount(
|
|
config.server.root_path.clone() + "/files",
|
|
FileServer::new(
|
|
config.file_dir.clone(),
|
|
Options::Missing | Options::NormalizeDirs,
|
|
),
|
|
)
|
|
.manage(database)
|
|
.manage(config)
|
|
.configure(rocket_config)
|
|
.launch()
|
|
.await;
|
|
|
|
// Ensure the server gracefully shuts down
|
|
rocket.expect("Server failed to shutdown gracefully");
|
|
|
|
info!("Stopping database cleaning thread...");
|
|
shutdown
|
|
.send(())
|
|
.await
|
|
.expect("Failed to stop cleaner thread.");
|
|
info!("Stopping database cleaning thread completed successfully.");
|
|
|
|
info!("Saving database on shutdown...");
|
|
local_db.write().unwrap().save();
|
|
info!("Saving database completed successfully.");
|
|
}
|