From 36fa6598a837378dfce6d81175f2b8d6fbf90bd5 Mon Sep 17 00:00:00 2001 From: Daniel Szabo Date: Mon, 2 May 2022 16:53:10 +0100 Subject: [PATCH] File upload and persistence extension - index.html extended with form input - pasta.html and pastalist.html show link to /file/{pasta.id}/{filename} path - files are saved in pasta_data folder - all data is now stored in pasta_data/database.json - changed pastalist.html date format to exclude year - added custom 404 error handler --- Cargo.toml | 30 +++++--- src/animalnumbers.rs | 82 +++++++++----------- src/dbio.rs | 56 ++++++++++++++ src/main.rs | 161 +++++++++++++++++++++++++++++---------- src/pasta.rs | 83 +++++++++----------- templates/error.html | 10 +++ templates/index.html | 10 ++- templates/pasta.html | 7 +- templates/pastalist.html | 155 +++++++++++++++++++------------------ 9 files changed, 371 insertions(+), 223 deletions(-) create mode 100644 src/dbio.rs create mode 100644 templates/error.html diff --git a/Cargo.toml b/Cargo.toml index 94b99e8..1247c7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,21 @@ [package] -name = "microbin" -version = "0.1.0" -edition = "2021" +name="microbin" +version="0.2.0" +edition="2021" [dependencies] -actix-web = "4" -actix-files = "0.6.0" -serde = { version = "1.0", features = ["derive"] } -askama = "0.10" -askama-filters = { version = "0.1.3", features = ["chrono"] } -chrono = "0.4.19" -rand = "0.8.5" -linkify = "0.8.1" -clap = { version = "3.1.12", features = ["derive"] } +actix-web="4" +actix-files="0.6.0" +serde={ version = "1.0", features = ["derive"] } +serde_json = "1.0.80" +askama="0.10" +askama-filters={ version = "0.1.3", features = ["chrono"] } +chrono="0.4.19" +rand="0.8.5" +linkify="0.8.1" +clap={ version = "3.1.12", features = ["derive"] } +actix-multipart = "0.4.0" +futures = "0.3" +sanitize-filename = "0.3.0" +log = "0.4" +env_logger = "0.9.0" \ No newline at end of file diff --git a/src/animalnumbers.rs b/src/animalnumbers.rs index a2ef104..c6a7e27 100644 --- a/src/animalnumbers.rs +++ b/src/animalnumbers.rs @@ -1,61 +1,53 @@ -const animal_names: &[&str] = &[ - "ant", "eel", "mole", "sloth", - "ape", "emu", "monkey", "snail", - "bat", "falcon", "mouse", "snake", - "bear", "fish", "otter", "spider", - "bee", "fly", "parrot", "squid", - "bird", "fox", "panda", "swan", - "bison", "frog", "pig", "tiger", - "camel", "gecko", "pigeon", "toad", - "cat", "goat", "pony", "turkey", - "cobra", "goose", "pug", "turtle", - "crow", "hawk", "rabbit", "viper", - "deer", "horse", "rat", "wasp", - "dog", "jaguar", "raven", "whale", - "dove", "koala", "seal", "wolf", - "duck", "lion", "shark", "worm", - "eagle", "lizard", "sheep", "zebra", +const ANIMAL_NAMES: &[&str] = &[ + "ant", "eel", "mole", "sloth", "ape", "emu", "monkey", "snail", "bat", "falcon", "mouse", + "snake", "bear", "fish", "otter", "spider", "bee", "fly", "parrot", "squid", "bird", "fox", + "panda", "swan", "bison", "frog", "pig", "tiger", "camel", "gecko", "pigeon", "toad", "cat", + "goat", "pony", "turkey", "cobra", "goose", "pug", "turtle", "crow", "hawk", "rabbit", "viper", + "deer", "horse", "rat", "wasp", "dog", "jaguar", "raven", "whale", "dove", "koala", "seal", + "wolf", "duck", "lion", "shark", "worm", "eagle", "lizard", "sheep", "zebra", ]; pub fn to_animal_names(mut n: u64) -> String { - let mut result: Vec<&str> = Vec::new(); + let mut result: Vec<&str> = Vec::new(); - if n == 0 { - return animal_names[0].parse().unwrap(); - } else if n == 1 { - return animal_names[1].parse().unwrap(); - } + if n == 0 { + return ANIMAL_NAMES[0].parse().unwrap(); + } else if n == 1 { + return ANIMAL_NAMES[1].parse().unwrap(); + } - // max 4 animals so 6 * 6 = 64 bits - let mut power = 6; - loop { - let d = n / animal_names.len().pow(power) as u64; + // max 4 animals so 6 * 6 = 64 bits + let mut power = 6; + loop { + let d = n / ANIMAL_NAMES.len().pow(power) as u64; - if !(result.is_empty() && d == 0) { - result.push(animal_names[d as usize]); - } + if !(result.is_empty() && d == 0) { + result.push(ANIMAL_NAMES[d as usize]); + } - n -= d * animal_names.len().pow(power) as u64; + n -= d * ANIMAL_NAMES.len().pow(power) as u64; - if power > 0 { - power -= 1; - } else { break; } - } + if power > 0 { + power -= 1; + } else { + break; + } + } - result.join("-") + result.join("-") } pub fn to_u64(n: &str) -> u64 { - let mut result: u64 = 0; + let mut result: u64 = 0; - let mut animals: Vec<&str> = n.split("-").collect(); + let animals: Vec<&str> = n.split("-").collect(); - let mut pow = animals.len(); - for i in 0..animals.len() { - pow -= 1; - result += (animal_names.iter().position(|&r| r == animals[i]).unwrap() * animal_names.len().pow(pow as u32)) as u64; - } + let mut pow = animals.len(); + for i in 0..animals.len() { + pow -= 1; + result += (ANIMAL_NAMES.iter().position(|&r| r == animals[i]).unwrap() + * ANIMAL_NAMES.len().pow(pow as u32)) as u64; + } - result + result } - diff --git a/src/dbio.rs b/src/dbio.rs new file mode 100644 index 0000000..b051a03 --- /dev/null +++ b/src/dbio.rs @@ -0,0 +1,56 @@ +use std::fs::File; +use std::io::{BufReader, BufWriter, Error}; +use std::{fmt, io}; + +use chrono::{DateTime, Datelike, NaiveDateTime, Timelike, Utc}; +use log::log; + +use crate::{to_animal_names, Pasta}; + +static DATABASE_PATH: &'static str = "pasta_data/database.json"; + +pub fn save_to_file(pasta_data: &Vec) { + let mut file = File::create(DATABASE_PATH); + match file { + Ok(_) => { + let mut writer = BufWriter::new(file.unwrap()); + serde_json::to_writer(&mut writer, &pasta_data); + } + Err(_) => { + log::info!("Database file {} not found!", DATABASE_PATH); + file = File::create(DATABASE_PATH); + match file { + Ok(_) => { + log::info!("Database file {} created.", DATABASE_PATH); + save_to_file(pasta_data); + } + Err(err) => { + log::error!( + "Failed to create database file {}: {}!", + &DATABASE_PATH, + &err + ); + panic!("Failed to create database file {}: {}!", DATABASE_PATH, err) + } + } + } + } +} + +pub fn load_from_file() -> io::Result> { + let mut file = File::open(DATABASE_PATH); + match file { + Ok(_) => { + let mut reader = BufReader::new(file.unwrap()); + let data: Vec = serde_json::from_reader(&mut reader).unwrap(); + Ok(data) + } + Err(_) => { + log::info!("Database file {} not found!", DATABASE_PATH); + save_to_file(&Vec::::new()); + + log::info!("Database file {} created.", DATABASE_PATH); + load_from_file() + } + } +} diff --git a/src/main.rs b/src/main.rs index 6d39676..00e9005 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,27 @@ extern crate core; -use actix_files as fs; -use actix_web::web::Data; -use actix_web::{get, post, web, App, HttpRequest, HttpResponse, HttpServer, Responder, Result}; -use askama::Template; -use clap::Parser; -use linkify::{LinkFinder, LinkKind}; -use rand::Rng; -use std::path::PathBuf; +use env_logger::Builder; +use std::io::Write; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; +use actix_files as fs; +use actix_multipart::Multipart; +use actix_web::{get, web, App, Error, HttpResponse, HttpServer, Responder}; +use askama::Template; +use chrono::Local; +use clap::Parser; +use futures::TryStreamExt as _; +use linkify::{LinkFinder, LinkKind}; +use log::LevelFilter; +use rand::Rng; + use crate::animalnumbers::{to_animal_names, to_u64}; -use crate::pasta::{Pasta, PastaFormData}; +use crate::dbio::save_to_file; +use crate::pasta::Pasta; mod animalnumbers; +mod dbio; mod pasta; struct AppState { @@ -32,6 +39,10 @@ struct Args { #[template(path = "index.html")] struct IndexTemplate {} +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate {} + #[derive(Template)] #[template(path = "pasta.html")] struct PastaTemplate<'a> { @@ -51,48 +62,94 @@ async fn index() -> impl Responder { .body(IndexTemplate {}.render().unwrap()) } -#[post("/create")] -async fn create(data: web::Data, pasta: web::Form) -> impl Responder { - let mut pastas = data.pastas.lock().unwrap(); +async fn not_found() -> Result { + Ok(HttpResponse::Found() + .content_type("text/html") + .body(ErrorTemplate {}.render().unwrap())) +} - let inner_pasta = pasta.into_inner(); +async fn create(data: web::Data, mut payload: Multipart) -> Result { + let mut pastas = data.pastas.lock().unwrap(); let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(n) => n.as_secs(), Err(_) => panic!("SystemTime before UNIX EPOCH!"), } as i64; - let expiration = match inner_pasta.expiration.as_str() { - "1min" => timenow + 60, - "10min" => timenow + 60 * 10, - "1hour" => timenow + 60 * 60, - "24hour" => timenow + 60 * 60 * 24, - "1week" => timenow + 60 * 60 * 24 * 7, - "never" => 0, - _ => panic!("Unexpected expiration time!"), - }; - - let pasta_type = if is_valid_url(inner_pasta.content.as_str()) { - String::from("url") - } else { - String::from("text") - }; - - let new_pasta = Pasta { + let mut new_pasta = Pasta { id: rand::thread_rng().gen::() as u64, - content: inner_pasta.content, + content: String::from("No Text Content"), + file: String::from("no-file"), created: timenow, - pasta_type, - expiration, + pasta_type: String::from(""), + expiration: 0, }; + while let Some(mut field) = payload.try_next().await? { + match field.name() { + "expiration" => { + while let Some(chunk) = field.try_next().await? { + new_pasta.expiration = match std::str::from_utf8(&chunk).unwrap() { + "1min" => timenow + 60, + "10min" => timenow + 60 * 10, + "1hour" => timenow + 60 * 60, + "24hour" => timenow + 60 * 60 * 24, + "1week" => timenow + 60 * 60 * 24 * 7, + "never" => 0, + _ => panic!("Unexpected expiration time!"), + }; + } + + continue; + } + "content" => { + while let Some(chunk) = field.try_next().await? { + new_pasta.content = std::str::from_utf8(&chunk).unwrap().to_string(); + new_pasta.pasta_type = if is_valid_url(new_pasta.content.as_str()) { + String::from("url") + } else { + String::from("text") + }; + } + continue; + } + "file" => { + let content_disposition = field.content_disposition(); + + let filename = match content_disposition.get_filename() { + Some("") => continue, + Some(filename) => filename.replace(' ', "_").to_string(), + None => continue, + }; + + std::fs::create_dir_all(format!("./pasta_data/{}", &new_pasta.id_as_animals())) + .unwrap(); + + let filepath = format!("./pasta_data/{}/{}", &new_pasta.id_as_animals(), &filename); + + new_pasta.file = filename; + + let mut f = web::block(|| std::fs::File::create(filepath)).await??; + + while let Some(chunk) = field.try_next().await? { + f = web::block(move || f.write_all(&chunk).map(|_| f)).await??; + } + + new_pasta.pasta_type = String::from("text"); + } + _ => {} + } + } + let id = new_pasta.id; pastas.push(new_pasta); - HttpResponse::Found() + save_to_file(&pastas); + + Ok(HttpResponse::Found() .append_header(("Location", format!("/pasta/{}", to_animal_names(id)))) - .finish() + .finish()) } #[get("/pasta/{id}")] @@ -110,7 +167,9 @@ async fn getpasta(data: web::Data, id: web::Path) -> HttpRespo } } - HttpResponse::Found().body("Pasta not found! :-(") + HttpResponse::Found() + .content_type("text/html") + .body(ErrorTemplate {}.render().unwrap()) } #[get("/url/{id}")] @@ -138,6 +197,7 @@ async fn redirecturl(data: web::Data, id: web::Path) -> HttpRe #[get("/raw/{id}")] async fn getrawpasta(data: web::Data, id: web::Path) -> String { let mut pastas = data.pastas.lock().unwrap(); + let id = to_u64(&*id.into_inner()); remove_expired(&mut pastas); @@ -166,7 +226,6 @@ async fn remove(data: web::Data, id: web::Path) -> HttpRespons .finish(); } } - HttpResponse::Found().body("Pasta not found! :-(") } @@ -184,26 +243,44 @@ async fn list(data: web::Data) -> HttpResponse { #[actix_web::main] async fn main() -> std::io::Result<()> { let args = Args::parse(); - println!( - "{}", - format!("Listening on http://127.0.0.1:{}", args.port.to_string()) + + Builder::new() + .format(|buf, record| { + writeln!( + buf, + "{} [{}] - {}", + Local::now().format("%Y-%m-%dT%H:%M:%S"), + record.level(), + record.args() + ) + }) + .filter(None, LevelFilter::Info) + .init(); + + log::info!( + "MicroBin listening on http://127.0.0.1:{}", + args.port.to_string() ); + std::fs::create_dir_all("./pasta_data").unwrap(); + let data = web::Data::new(AppState { - pastas: Mutex::new(Vec::new()), + pastas: Mutex::new(dbio::load_from_file().unwrap()), }); HttpServer::new(move || { App::new() .app_data(data.clone()) .service(index) - .service(create) .service(getpasta) .service(redirecturl) .service(getrawpasta) .service(remove) .service(list) .service(fs::Files::new("/static", "./static")) + .service(fs::Files::new("/file", "./pasta_data")) + .service(web::resource("/upload").route(web::post().to(create))) + .default_service(web::route().to(not_found)) }) .bind(format!("127.0.0.1:{}", args.port.to_string()))? .run() diff --git a/src/pasta.rs b/src/pasta.rs index dbeaff3..5ac8844 100644 --- a/src/pasta.rs +++ b/src/pasta.rs @@ -1,58 +1,51 @@ use std::fmt; -use actix_web::cookie::time::macros::format_description; -use chrono::{Datelike, DateTime, NaiveDateTime, Timelike, Utc}; -use serde::Deserialize; + +use chrono::{DateTime, Datelike, NaiveDateTime, Timelike, Utc}; +use serde::{Deserialize, Serialize}; + use crate::to_animal_names; +#[derive(Serialize, Deserialize)] pub struct Pasta { - pub id: u64, - pub content: String, - pub created: i64, - pub expiration: i64, - pub pasta_type: String -} - -#[derive(Deserialize)] -pub struct PastaFormData { - pub content: String, - pub expiration: String + pub id: u64, + pub content: String, + pub file: String, + pub created: i64, + pub expiration: i64, + pub pasta_type: String, } impl Pasta { + pub fn id_as_animals(&self) -> String { + to_animal_names(self.id) + } - pub fn idAsAnimals(&self) -> String { - to_animal_names(self.id) - } - - pub fn createdAsString(&self) -> String { - let date = DateTime::::from_utc(NaiveDateTime::from_timestamp(self.created, 0), Utc); - format!( - "{}-{:02}-{:02} {}:{}", - date.year(), - date.month(), - date.day(), - date.hour(), - date.minute(), - ) - } - - pub fn expirationAsString(&self) -> String { - let date = DateTime::::from_utc(NaiveDateTime::from_timestamp(self.expiration, 0), Utc); - format!( - "{}-{:02}-{:02} {}:{}", - date.year(), - date.month(), - date.day(), - date.hour(), - date.minute(), - ) - } + pub fn created_as_string(&self) -> String { + let date = DateTime::::from_utc(NaiveDateTime::from_timestamp(self.created, 0), Utc); + format!( + "{:02}-{:02} {}:{}", + date.month(), + date.day(), + date.hour(), + date.minute(), + ) + } + pub fn expiration_as_string(&self) -> String { + let date = + DateTime::::from_utc(NaiveDateTime::from_timestamp(self.expiration, 0), Utc); + format!( + "{:02}-{:02} {}:{}", + date.month(), + date.day(), + date.hour(), + date.minute(), + ) + } } - impl fmt::Display for Pasta { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.content) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.content) + } } diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..05f55d5 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,10 @@ +{% include "header.html" %} +
+

404

+Not Found +
+
+ Go Home +
+
+{% include "footer.html" %} diff --git a/templates/index.html b/templates/index.html index 95f34b4..848d274 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,8 +1,8 @@ {% include "header.html" %} -
+

- @@ -17,7 +17,11 @@

- + +
+ +
+
{% include "footer.html" %} diff --git a/templates/pasta.html b/templates/pasta.html index 1b7a8c5..4701c2a 100644 --- a/templates/pasta.html +++ b/templates/pasta.html @@ -1,4 +1,9 @@ {% include "header.html" %} -Raw Pasta +Raw Text Content +{% if pasta.file != "no-file" %} +Attached file '{{pasta.file}}' +{%- endif %} +Remove
{{pasta}}
+ {% include "footer.html" %} diff --git a/templates/pastalist.html b/templates/pastalist.html index 4369153..f380763 100644 --- a/templates/pastalist.html +++ b/templates/pastalist.html @@ -2,94 +2,99 @@ {% if pastas.is_empty() %} +

- No pastas yet. 😔 Create one here. + No pastas yet. 😔 Create one here.

+
{%- else %}
- + - + - - - - + + + + - - + + {% for pasta in pastas %} - {% if pasta.pasta_type == "text" %} - - - - - - - {%- endif %} - {% endfor %} - -
Pastas
- Key - - Created - - Expiration - + + Key + + Created + + Expiration + -
- {{pasta.idAsAnimals()}} - - {{pasta.createdAsString()}} - - {{pasta.expirationAsString()}} - - Raw - Remove -
-
- - - - - - - - - - - - - {% for pasta in pastas %} - {% if pasta.pasta_type == "url" %} - - - - - - + {% if pasta.pasta_type == "text" %} + + + + + + + {%- endif %} {% endfor %} - + +
URL Redirects
- Key - - Created - - Expiration - - -
- {{pasta.idAsAnimals()}} - - {{pasta.createdAsString()}} - - {{pasta.expirationAsString()}} - - Raw - Remove -
+ {{pasta.id_as_animals()}} + + {{pasta.created_as_string()}} + + {{pasta.expiration_as_string()}} + + Raw + {% if pasta.file != "no-file" %} + File {%- endif %} + Remove +
+
+ + + + + + + + + + + + + {% for pasta in pastas %} + {% if pasta.pasta_type == "url" %} + + + + + + + {%- endif %} + {% endfor %} +
URL Redirects
+ Key + + Created + + Expiration + + +
+ {{pasta.id_as_animals()}} + + {{pasta.created_as_string()}} + + {{pasta.expiration_as_string()}} + + Raw + Remove +

{%- endif %}