diff --git a/Cargo.toml b/Cargo.toml index ad68320..94b99e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,5 @@ 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"] } diff --git a/README.MD b/README.MD index db42eb2..457df0b 100644 --- a/README.MD +++ b/README.MD @@ -2,10 +2,10 @@ ![Screenshot](git/index.png) -MicroBin is a super tiny and simple self hosted pastebin app written in Rust. The executable is around 6MB and it uses 2MB memory (plus your pastas). +MicroBin is a super tiny and simple self hosted pastebin app written in Rust. The executable is only a few megabytes and uses very little memory (plus your pastas). ### Installation -Simply clone the repository, build it with `cargo build --release` and run the `microbin` executable in the created `target/release/` directory. It will start on port 8080. +Simply clone the repository, build it with `cargo build --release` and run the `microbin` executable in the created `target/release/` directory. It will start on port 8080 but you can change this with `-p` or `--port` arguments. To install it as a service on your Linux machine, create a file called `/etc/systemd/system/microbin.service`, paste this into it with the value of `ExecStart` replaced with the actual path to microbin on your machine. @@ -24,15 +24,16 @@ WantedBy=multi-user.target Then start the service with `systemctl start microbin` and enable it on boot with `systemctl enable microbin`. ### Features -- No CSS or JS, super lightweight and simple +- Very little CSS and no JS, super lightweight and simple (see [water.css](https://github.com/kognise/water.css)) - Animal names instead of random numbers for pasta identifiers - Automatically expiring pastas - Never expiring pastas - Listing and manually removing pastas +- URL shortening and redirection ### Needed improvements - Persisting pastas on disk (currently they are lost on restart) - Removing pasta after N reads - File uploads -- URL shortening +- ~~URL shortening~~ (added on 23 April 2022) diff --git a/git/index.png b/git/index.png index 27ea961..3b16e64 100644 Binary files a/git/index.png and b/git/index.png differ diff --git a/src/main.rs b/src/main.rs index 1d08c81..d76ce71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,32 @@ 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 regex::Regex; use std::path::PathBuf; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use actix_files::NamedFile; -use actix_web::{App, get, HttpRequest, HttpResponse, HttpServer, post, Responder, Result, web}; -use actix_web::web::Data; -use askama::Template; -use rand::Rng; - use crate::animalnumbers::{to_animal_names, to_u64}; use crate::pasta::{Pasta, PastaFormData}; -mod pasta; mod animalnumbers; +mod pasta; struct AppState { - pastas: Mutex>, + pastas: Mutex>, +} + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + #[clap(short, long, default_value_t = 8080)] + port: u32, } #[derive(Template)] @@ -27,130 +36,192 @@ struct IndexTemplate {} #[derive(Template)] #[template(path = "pasta.html")] struct PastaTemplate<'a> { - pasta: &'a Pasta, + pasta: &'a Pasta, } #[derive(Template)] #[template(path = "pastalist.html")] struct PastaListTemplate<'a> { - pastas: &'a Vec, + pastas: &'a Vec, } #[get("/")] async fn index() -> impl Responder { - HttpResponse::Found().content_type("text/html").body(IndexTemplate {}.render().unwrap()) + HttpResponse::Found() + .content_type("text/html") + .body(IndexTemplate {}.render().unwrap()) } #[post("/create")] async fn create(data: web::Data, pasta: web::Form) -> impl Responder { - let mut pastas = data.pastas.lock().unwrap(); + let mut pastas = data.pastas.lock().unwrap(); - let mut innerPasta = pasta.into_inner(); + let inner_pasta = pasta.into_inner(); - let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(n) => n.as_secs(), - Err(_) => panic!("SystemTime before UNIX EPOCH!"), - } as i64; + 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 innerPasta.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 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 mut newPasta = Pasta { - id: rand::thread_rng().gen::() as u64, - content: innerPasta.content, - created: timenow, - expiration, - }; + let pasta_type = if is_valid_url(inner_pasta.content.as_str()) { + String::from("url") + } else { + String::from("text") + }; - let id = newPasta.id; + let new_pasta = Pasta { + id: rand::thread_rng().gen::() as u64, + content: inner_pasta.content, + created: timenow, + pasta_type, + expiration, + }; - pastas.push(newPasta); + let id = new_pasta.id; - HttpResponse::Found().append_header(("Location", format!("/pasta/{}", to_animal_names(id)))).finish() + pastas.push(new_pasta); + + HttpResponse::Found() + .append_header(("Location", format!("/pasta/{}", to_animal_names(id)))) + .finish() } #[get("/pasta/{id}")] async fn getpasta(data: web::Data, id: web::Path) -> HttpResponse { - let mut pastas = data.pastas.lock().unwrap(); - let id = to_u64(&*id.into_inner()); + let mut pastas = data.pastas.lock().unwrap(); + let id = to_u64(&*id.into_inner()); - remove_expired(&mut pastas); + remove_expired(&mut pastas); - for pasta in pastas.iter() { - if pasta.id == id { - return HttpResponse::Found().content_type("text/html").body(PastaTemplate { pasta }.render().unwrap()); - } - } + for pasta in pastas.iter() { + if pasta.id == id { + return HttpResponse::Found() + .content_type("text/html") + .body(PastaTemplate { pasta }.render().unwrap()); + } + } - HttpResponse::Found().body("Pasta not found! :-(") + HttpResponse::Found().body("Pasta not found! :-(") } -#[get("/rawpasta/{id}")] +#[get("/url/{id}")] +async fn redirecturl(data: web::Data, id: web::Path) -> HttpResponse { + let mut pastas = data.pastas.lock().unwrap(); + let id = to_u64(&*id.into_inner()); + + remove_expired(&mut pastas); + + for pasta in pastas.iter() { + if pasta.id == id { + if pasta.pasta_type == "url" { + return HttpResponse::Found() + .append_header(("Location", String::from(&pasta.content))) + .finish(); + } else { + return HttpResponse::Found().body("This is not a valid URL. :-("); + } + } + } + + HttpResponse::Found().body("Pasta not found! :-(") +} + +#[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()); + let mut pastas = data.pastas.lock().unwrap(); + let id = to_u64(&*id.into_inner()); - remove_expired(&mut pastas); + remove_expired(&mut pastas); - for pasta in pastas.iter() { - if pasta.id == id { - return pasta.content.to_owned(); - } - } + for pasta in pastas.iter() { + if pasta.id == id { + return pasta.content.to_owned(); + } + } - String::from("Pasta not found! :-(") + String::from("Pasta not found! :-(") } #[get("/remove/{id}")] async fn remove(data: web::Data, id: web::Path) -> HttpResponse { - let mut pastas = data.pastas.lock().unwrap(); - let id = to_u64(&*id.into_inner()); + let mut pastas = data.pastas.lock().unwrap(); + let id = to_u64(&*id.into_inner()); - remove_expired(&mut pastas); + remove_expired(&mut pastas); - for (i, pasta) in pastas.iter().enumerate() { - if pasta.id == id { - pastas.remove(i); - return HttpResponse::Found().append_header(("Location", "/pastalist")).finish(); - } - } + for (i, pasta) in pastas.iter().enumerate() { + if pasta.id == id { + pastas.remove(i); + return HttpResponse::Found() + .append_header(("Location", "/pastalist")) + .finish(); + } + } - HttpResponse::Found().body("Pasta not found! :-(") + HttpResponse::Found().body("Pasta not found! :-(") } #[get("/pastalist")] async fn list(data: web::Data) -> HttpResponse { - let mut pastas = data.pastas.lock().unwrap(); + let mut pastas = data.pastas.lock().unwrap(); - remove_expired(&mut pastas); + remove_expired(&mut pastas); - HttpResponse::Found().content_type("text/html").body(PastaListTemplate { pastas: &pastas }.render().unwrap()) + HttpResponse::Found() + .content_type("text/html") + .body(PastaListTemplate { pastas: &pastas }.render().unwrap()) } #[actix_web::main] async fn main() -> std::io::Result<()> { - let data = web::Data::new(AppState { - pastas: Mutex::new(Vec::new()), - }); + let args = Args::parse(); + println!( + "{}", + format!("Listening on http://127.0.0.1:{}", args.port.to_string()) + ); - HttpServer::new(move || App::new().app_data(data.clone()).service(index).service(create).service(getpasta).service(getrawpasta).service(remove).service(list) - ).bind("127.0.0.1:8080")?.run().await + let data = web::Data::new(AppState { + pastas: Mutex::new(Vec::new()), + }); + + 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")) + }) + .bind(format!("127.0.0.1:{}", args.port.to_string()))? + .run() + .await } fn remove_expired(pastas: &mut Vec) { - let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(n) => n.as_secs(), - Err(_) => panic!("SystemTime before UNIX EPOCH!"), - } as i64; + let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(n) => n.as_secs(), + Err(_) => panic!("SystemTime before UNIX EPOCH!"), + } as i64; - pastas.retain(|p| { - p.expiration == 0 || p.expiration > timenow - }); + pastas.retain(|p| p.expiration == 0 || p.expiration > timenow); +} + +fn is_valid_url(url: &str) -> bool { + let finder = LinkFinder::new(); + let spans: Vec<_> = finder.spans(url).collect(); + spans[0].as_str() == url && Some(&LinkKind::Url) == spans[0].kind() } diff --git a/src/pasta.rs b/src/pasta.rs index 9df8921..dbeaff3 100644 --- a/src/pasta.rs +++ b/src/pasta.rs @@ -8,7 +8,8 @@ pub struct Pasta { pub id: u64, pub content: String, pub created: i64, - pub expiration: i64 + pub expiration: i64, + pub pasta_type: String } #[derive(Deserialize)] diff --git a/templates/header.html b/templates/header.html index 1434ed7..2bfb0ad 100644 --- a/templates/header.html +++ b/templates/header.html @@ -3,22 +3,26 @@ MicroBin - + + + font-size: 1.1em; + ">
- MicroBin + μ MicroBin -| -New Pasta -| -Pasta List -| -GitHub -
\ No newline at end of file +New Pasta + +Pasta List + +GitHub + +
diff --git a/templates/index.html b/templates/index.html index b75866b..95f34b4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,6 +1,6 @@ {% include "header.html" %}
- +

-
+
+
-{% include "footer.html" %} \ No newline at end of file +{% include "footer.html" %} diff --git a/templates/pasta.html b/templates/pasta.html index 4f58c08..1b7a8c5 100644 --- a/templates/pasta.html +++ b/templates/pasta.html @@ -1,6 +1,4 @@ {% include "header.html" %} -Raw Pasta -
-{{pasta}}
-
-{% include "footer.html" %} \ No newline at end of file +Raw Pasta +
{{pasta}}
+{% include "footer.html" %} diff --git a/templates/pastalist.html b/templates/pastalist.html index 741f1e7..4369153 100644 --- a/templates/pastalist.html +++ b/templates/pastalist.html @@ -6,8 +6,13 @@ No pastas yet. 😔 Create one here.

{%- else %} - - +
+
+ + + + + @@ -21,22 +26,71 @@ + + {% for pasta in pastas %} - - - - - - + {% if pasta.pasta_type == "text" %} + + + + + + + {%- endif %} {% endfor %} + +
Pastas
Key
- {{pasta.idAsAnimals()}} - - {{pasta.createdAsString()}} - - {{pasta.expirationAsString()}} - - Remove -
+ {{pasta.idAsAnimals()}} + + {{pasta.createdAsString()}} + + {{pasta.expirationAsString()}} + + Raw + Remove +
+
+ + + + + + + + + + + + + {% for pasta in pastas %} + {% if pasta.pasta_type == "url" %} + + + + + + + {%- endif %} + {% endfor %} +
URL Redirects
+ Key + + Created + + Expiration + + +
+ {{pasta.idAsAnimals()}} + + {{pasta.createdAsString()}} + + {{pasta.expirationAsString()}} + + Raw + Remove +
+
{%- endif %} -{% include "footer.html" %} \ No newline at end of file +{% include "footer.html" %}