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
This commit is contained in:
Daniel Szabo 2022-05-02 16:53:10 +01:00
parent c98aad7256
commit 36fa6598a8
9 changed files with 371 additions and 223 deletions

View file

@ -1,15 +1,21 @@
[package] [package]
name = "microbin" name="microbin"
version = "0.1.0" version="0.2.0"
edition = "2021" edition="2021"
[dependencies] [dependencies]
actix-web = "4" actix-web="4"
actix-files = "0.6.0" actix-files="0.6.0"
serde = { version = "1.0", features = ["derive"] } serde={ version = "1.0", features = ["derive"] }
askama = "0.10" serde_json = "1.0.80"
askama-filters = { version = "0.1.3", features = ["chrono"] } askama="0.10"
chrono = "0.4.19" askama-filters={ version = "0.1.3", features = ["chrono"] }
rand = "0.8.5" chrono="0.4.19"
linkify = "0.8.1" rand="0.8.5"
clap = { version = "3.1.12", features = ["derive"] } 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"

View file

@ -1,61 +1,53 @@
const animal_names: &[&str] = &[ const ANIMAL_NAMES: &[&str] = &[
"ant", "eel", "mole", "sloth", "ant", "eel", "mole", "sloth", "ape", "emu", "monkey", "snail", "bat", "falcon", "mouse",
"ape", "emu", "monkey", "snail", "snake", "bear", "fish", "otter", "spider", "bee", "fly", "parrot", "squid", "bird", "fox",
"bat", "falcon", "mouse", "snake", "panda", "swan", "bison", "frog", "pig", "tiger", "camel", "gecko", "pigeon", "toad", "cat",
"bear", "fish", "otter", "spider", "goat", "pony", "turkey", "cobra", "goose", "pug", "turtle", "crow", "hawk", "rabbit", "viper",
"bee", "fly", "parrot", "squid", "deer", "horse", "rat", "wasp", "dog", "jaguar", "raven", "whale", "dove", "koala", "seal",
"bird", "fox", "panda", "swan", "wolf", "duck", "lion", "shark", "worm", "eagle", "lizard", "sheep", "zebra",
"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 { 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 { if n == 0 {
return animal_names[0].parse().unwrap(); return ANIMAL_NAMES[0].parse().unwrap();
} else if n == 1 { } else if n == 1 {
return animal_names[1].parse().unwrap(); return ANIMAL_NAMES[1].parse().unwrap();
} }
// max 4 animals so 6 * 6 = 64 bits // max 4 animals so 6 * 6 = 64 bits
let mut power = 6; let mut power = 6;
loop { loop {
let d = n / animal_names.len().pow(power) as u64; let d = n / ANIMAL_NAMES.len().pow(power) as u64;
if !(result.is_empty() && d == 0) { if !(result.is_empty() && d == 0) {
result.push(animal_names[d as usize]); 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 { if power > 0 {
power -= 1; power -= 1;
} else { break; } } else {
} break;
}
}
result.join("-") result.join("-")
} }
pub fn to_u64(n: &str) -> u64 { 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(); let mut pow = animals.len();
for i in 0..animals.len() { for i in 0..animals.len() {
pow -= 1; pow -= 1;
result += (animal_names.iter().position(|&r| r == animals[i]).unwrap() * animal_names.len().pow(pow as u32)) as u64; result += (ANIMAL_NAMES.iter().position(|&r| r == animals[i]).unwrap()
} * ANIMAL_NAMES.len().pow(pow as u32)) as u64;
}
result result
} }

56
src/dbio.rs Normal file
View file

@ -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<Pasta>) {
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<Vec<Pasta>> {
let mut file = File::open(DATABASE_PATH);
match file {
Ok(_) => {
let mut reader = BufReader::new(file.unwrap());
let data: Vec<Pasta> = serde_json::from_reader(&mut reader).unwrap();
Ok(data)
}
Err(_) => {
log::info!("Database file {} not found!", DATABASE_PATH);
save_to_file(&Vec::<Pasta>::new());
log::info!("Database file {} created.", DATABASE_PATH);
load_from_file()
}
}
}

View file

@ -1,20 +1,27 @@
extern crate core; extern crate core;
use actix_files as fs; use env_logger::Builder;
use actix_web::web::Data; use std::io::Write;
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 std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; 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::animalnumbers::{to_animal_names, to_u64};
use crate::pasta::{Pasta, PastaFormData}; use crate::dbio::save_to_file;
use crate::pasta::Pasta;
mod animalnumbers; mod animalnumbers;
mod dbio;
mod pasta; mod pasta;
struct AppState { struct AppState {
@ -32,6 +39,10 @@ struct Args {
#[template(path = "index.html")] #[template(path = "index.html")]
struct IndexTemplate {} struct IndexTemplate {}
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorTemplate {}
#[derive(Template)] #[derive(Template)]
#[template(path = "pasta.html")] #[template(path = "pasta.html")]
struct PastaTemplate<'a> { struct PastaTemplate<'a> {
@ -51,48 +62,94 @@ async fn index() -> impl Responder {
.body(IndexTemplate {}.render().unwrap()) .body(IndexTemplate {}.render().unwrap())
} }
#[post("/create")] async fn not_found() -> Result<HttpResponse, Error> {
async fn create(data: web::Data<AppState>, pasta: web::Form<PastaFormData>) -> impl Responder { Ok(HttpResponse::Found()
let mut pastas = data.pastas.lock().unwrap(); .content_type("text/html")
.body(ErrorTemplate {}.render().unwrap()))
}
let inner_pasta = pasta.into_inner(); async fn create(data: web::Data<AppState>, mut payload: Multipart) -> Result<HttpResponse, Error> {
let mut pastas = data.pastas.lock().unwrap();
let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(n) => n.as_secs(), Ok(n) => n.as_secs(),
Err(_) => panic!("SystemTime before UNIX EPOCH!"), Err(_) => panic!("SystemTime before UNIX EPOCH!"),
} as i64; } as i64;
let expiration = match inner_pasta.expiration.as_str() { let mut new_pasta = Pasta {
"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 {
id: rand::thread_rng().gen::<u16>() as u64, id: rand::thread_rng().gen::<u16>() as u64,
content: inner_pasta.content, content: String::from("No Text Content"),
file: String::from("no-file"),
created: timenow, created: timenow,
pasta_type, pasta_type: String::from(""),
expiration, 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; let id = new_pasta.id;
pastas.push(new_pasta); pastas.push(new_pasta);
HttpResponse::Found() save_to_file(&pastas);
Ok(HttpResponse::Found()
.append_header(("Location", format!("/pasta/{}", to_animal_names(id)))) .append_header(("Location", format!("/pasta/{}", to_animal_names(id))))
.finish() .finish())
} }
#[get("/pasta/{id}")] #[get("/pasta/{id}")]
@ -110,7 +167,9 @@ async fn getpasta(data: web::Data<AppState>, id: web::Path<String>) -> HttpRespo
} }
} }
HttpResponse::Found().body("Pasta not found! :-(") HttpResponse::Found()
.content_type("text/html")
.body(ErrorTemplate {}.render().unwrap())
} }
#[get("/url/{id}")] #[get("/url/{id}")]
@ -138,6 +197,7 @@ async fn redirecturl(data: web::Data<AppState>, id: web::Path<String>) -> HttpRe
#[get("/raw/{id}")] #[get("/raw/{id}")]
async fn getrawpasta(data: web::Data<AppState>, id: web::Path<String>) -> String { async fn getrawpasta(data: web::Data<AppState>, id: web::Path<String>) -> String {
let mut pastas = data.pastas.lock().unwrap(); let mut pastas = data.pastas.lock().unwrap();
let id = to_u64(&*id.into_inner()); let id = to_u64(&*id.into_inner());
remove_expired(&mut pastas); remove_expired(&mut pastas);
@ -166,7 +226,6 @@ async fn remove(data: web::Data<AppState>, id: web::Path<String>) -> HttpRespons
.finish(); .finish();
} }
} }
HttpResponse::Found().body("Pasta not found! :-(") HttpResponse::Found().body("Pasta not found! :-(")
} }
@ -184,26 +243,44 @@ async fn list(data: web::Data<AppState>) -> HttpResponse {
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let args = Args::parse(); let args = Args::parse();
println!(
"{}", Builder::new()
format!("Listening on http://127.0.0.1:{}", args.port.to_string()) .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 { let data = web::Data::new(AppState {
pastas: Mutex::new(Vec::new()), pastas: Mutex::new(dbio::load_from_file().unwrap()),
}); });
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.app_data(data.clone()) .app_data(data.clone())
.service(index) .service(index)
.service(create)
.service(getpasta) .service(getpasta)
.service(redirecturl) .service(redirecturl)
.service(getrawpasta) .service(getrawpasta)
.service(remove) .service(remove)
.service(list) .service(list)
.service(fs::Files::new("/static", "./static")) .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()))? .bind(format!("127.0.0.1:{}", args.port.to_string()))?
.run() .run()

View file

@ -1,58 +1,51 @@
use std::fmt; use std::fmt;
use actix_web::cookie::time::macros::format_description;
use chrono::{Datelike, DateTime, NaiveDateTime, Timelike, Utc}; use chrono::{DateTime, Datelike, NaiveDateTime, Timelike, Utc};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::to_animal_names; use crate::to_animal_names;
#[derive(Serialize, Deserialize)]
pub struct Pasta { pub struct Pasta {
pub id: u64, pub id: u64,
pub content: String, pub content: String,
pub created: i64, pub file: String,
pub expiration: i64, pub created: i64,
pub pasta_type: String pub expiration: i64,
} pub pasta_type: String,
#[derive(Deserialize)]
pub struct PastaFormData {
pub content: String,
pub expiration: String
} }
impl Pasta { impl Pasta {
pub fn id_as_animals(&self) -> String {
to_animal_names(self.id)
}
pub fn idAsAnimals(&self) -> String { pub fn created_as_string(&self) -> String {
to_animal_names(self.id) let date = DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(self.created, 0), Utc);
} format!(
"{:02}-{:02} {}:{}",
pub fn createdAsString(&self) -> String { date.month(),
let date = DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(self.created, 0), Utc); date.day(),
format!( date.hour(),
"{}-{:02}-{:02} {}:{}", date.minute(),
date.year(), )
date.month(), }
date.day(),
date.hour(),
date.minute(),
)
}
pub fn expirationAsString(&self) -> String {
let date = DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(self.expiration, 0), Utc);
format!(
"{}-{:02}-{:02} {}:{}",
date.year(),
date.month(),
date.day(),
date.hour(),
date.minute(),
)
}
pub fn expiration_as_string(&self) -> String {
let date =
DateTime::<Utc>::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 { impl fmt::Display for Pasta {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.content) write!(f, "{}", self.content)
} }
} }

10
templates/error.html Normal file
View file

@ -0,0 +1,10 @@
{% include "header.html" %}
<br>
<h2>404</h2>
<b>Not Found</b>
<br>
<br>
<a href="/" > Go Home</a>
<br>
<br>
{% include "footer.html" %}

View file

@ -1,8 +1,8 @@
{% include "header.html" %} {% include "header.html" %}
<form action="create" method="POST"> <form action="upload" method="POST" enctype="multipart/form-data">
<br> <br>
<label for="expiration">Expiration</label><br> <label for="expiration">Expiration</label><br>
<select name="expiration" id="expiration"> <select style="width: 100%;" name="expiration" id="expiration">
<optgroup label="Expire"> <optgroup label="Expire">
<option value="1min">1 minute</option> <option value="1min">1 minute</option>
<option value="10min">10 minutes</option> <option value="10min">10 minutes</option>
@ -17,7 +17,11 @@
<br> <br>
<textarea style="width: 100%; min-height: 100px" name="content" autofocus></textarea> <textarea style="width: 100%; min-height: 100px" name="content" autofocus></textarea>
<br> <br>
<input style="width: 100px; background-color: limegreen"; type="submit" value="Save"/> <label>File attachment</label>
<br>
<input style="width: 100%;" type="file" id="file" name="file">
<br>
<input style="width: 120px; background-color: limegreen" ; type="submit" value="Save"/>
<br> <br>
</form> </form>
{% include "footer.html" %} {% include "footer.html" %}

View file

@ -1,4 +1,9 @@
{% include "header.html" %} {% include "header.html" %}
<a href="/raw/{{pasta.idAsAnimals()}}">Raw Pasta</a> <a style="margin-right: 0.5rem" href="/raw/{{pasta.id_as_animals()}}">Raw Text Content</a>
{% if pasta.file != "no-file" %}
<a style="margin-right: 0.5rem; margin-left: 0.5rem" href="/file/{{pasta.id_as_animals()}}/{{pasta.file}}">Attached file '{{pasta.file}}'</a>
{%- endif %}
<a style="margin-right: 0.5rem; margin-left: 0.5rem" href="/remove/{{pasta.id_as_animals()}}">Remove</a>
<pre><code>{{pasta}}</code></pre> <pre><code>{{pasta}}</code></pre>
{% include "footer.html" %} {% include "footer.html" %}

View file

@ -2,94 +2,99 @@
{% if pastas.is_empty() %} {% if pastas.is_empty() %}
<br>
<p> <p>
No pastas yet. 😔 Create one <a href="/">here</a>. No pastas yet. 😔 Create one <a href="/">here</a>.
</p> </p>
<br>
{%- else %} {%- else %}
<br> <br>
<table style="width: 100%"> <table style="width: 100%">
<thead> <thead>
<tr> <tr>
<th colspan="4">Pastas</th> <th colspan="4">Pastas</th>
</tr> </tr>
<tr> <tr>
<th> <th>
Key Key
</th> </th>
<th> <th>
Created Created
</th> </th>
<th> <th>
Expiration Expiration
</th> </th>
<th> <th>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for pasta in pastas %} {% for pasta in pastas %}
{% if pasta.pasta_type == "text" %} {% if pasta.pasta_type == "text" %}
<tr> <tr>
<td> <td>
<a href="/pasta/{{pasta.idAsAnimals()}}">{{pasta.idAsAnimals()}}</a> <a href="/pasta/{{pasta.id_as_animals()}}">{{pasta.id_as_animals()}}</a>
</td> </td>
<td> <td>
{{pasta.createdAsString()}} {{pasta.created_as_string()}}
</td> </td>
<td> <td>
{{pasta.expirationAsString()}} {{pasta.expiration_as_string()}}
</td> </td>
<td> <td>
<a style="margin-right:1rem" href="/raw/{{pasta.idAsAnimals()}}">Raw</a> <a style="margin-right:1rem" href="/raw/{{pasta.id_as_animals()}}">Raw</a>
<a href="/remove/{{pasta.idAsAnimals()}}">Remove</a> {% if pasta.file != "no-file" %}
</td> <a style="margin-right:1rem" href="/file/{{pasta.id_as_animals()}}/{{pasta.file}}">File</a>
</tr>
{%- endif %}
{% endfor %}
</tbody>
</table>
<br>
<table>
<thead>
<tr>
<th colspan="4">URL Redirects</th>
</tr>
<tr >
<th>
Key
</th>
<th>
Created
</th>
<th>
Expiration
</th>
<th>
</th>
</tr>
</thead>
{% for pasta in pastas %}
{% if pasta.pasta_type == "url" %}
<tr>
<td>
<a href="/url/{{pasta.idAsAnimals()}}">{{pasta.idAsAnimals()}}</a>
</td>
<td>
{{pasta.createdAsString()}}
</td>
<td>
{{pasta.expirationAsString()}}
</td>
<td>
<a style="margin-right:1rem" href="/raw/{{pasta.idAsAnimals()}}">Raw</a>
<a href="/remove/{{pasta.idAsAnimals()}}">Remove</a>
</td>
</tr>
{%- endif %} {%- endif %}
<a href="/remove/{{pasta.id_as_animals()}}">Remove</a>
</td>
</tr>
{%- endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table>
<br>
<table>
<thead>
<tr>
<th colspan="4">URL Redirects</th>
</tr>
<tr >
<th>
Key
</th>
<th>
Created
</th>
<th>
Expiration
</th>
<th>
</th>
</tr>
</thead>
{% for pasta in pastas %}
{% if pasta.pasta_type == "url" %}
<tr>
<td>
<a href="/url/{{pasta.id_as_animals()}}">{{pasta.id_as_animals()}}</a>
</td>
<td>
{{pasta.created_as_string()}}
</td>
<td>
{{pasta.expiration_as_string()}}
</td>
<td>
<a style="margin-right:1rem" href="/raw/{{pasta.id_as_animals()}}">Raw</a>
<a href="/remove/{{pasta.id_as_animals()}}">Remove</a>
</td>
</tr>
{%- endif %}
{% endfor %}
</tbody>
</table> </table>
<br> <br>
{%- endif %} {%- endif %}