- added new pasta type URL and automatic redirection endpoint

- added water.css styling
- added CL argument to set port
- added raw URL to pasta list
This commit is contained in:
Dániel Szabó 2022-04-23 16:47:36 +01:00
parent e8b0e3a482
commit f56ffa98e4
9 changed files with 251 additions and 120 deletions

View file

@ -11,3 +11,5 @@ askama = "0.10"
askama-filters = { version = "0.1.3", features = ["chrono"] } askama-filters = { version = "0.1.3", features = ["chrono"] }
chrono = "0.4.19" chrono = "0.4.19"
rand = "0.8.5" rand = "0.8.5"
linkify = "0.8.1"
clap = { version = "3.1.12", features = ["derive"] }

View file

@ -2,10 +2,10 @@
![Screenshot](git/index.png) ![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 ### 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. 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`. Then start the service with `systemctl start microbin` and enable it on boot with `systemctl enable microbin`.
### Features ### 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 - Animal names instead of random numbers for pasta identifiers
- Automatically expiring pastas - Automatically expiring pastas
- Never expiring pastas - Never expiring pastas
- Listing and manually removing pastas - Listing and manually removing pastas
- URL shortening and redirection
### Needed improvements ### Needed improvements
- Persisting pastas on disk (currently they are lost on restart) - Persisting pastas on disk (currently they are lost on restart)
- Removing pasta after N reads - Removing pasta after N reads
- File uploads - File uploads
- URL shortening - ~~URL shortening~~ (added on 23 April 2022)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 625 KiB

View file

@ -1,25 +1,34 @@
extern crate core; 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::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::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::animalnumbers::{to_animal_names, to_u64};
use crate::pasta::{Pasta, PastaFormData}; use crate::pasta::{Pasta, PastaFormData};
mod pasta;
mod animalnumbers; mod animalnumbers;
mod pasta;
struct AppState { struct AppState {
pastas: Mutex<Vec<Pasta>>, pastas: Mutex<Vec<Pasta>>,
} }
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
#[clap(short, long, default_value_t = 8080)]
port: u32,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
struct IndexTemplate {} struct IndexTemplate {}
@ -38,42 +47,53 @@ struct PastaListTemplate<'a> {
#[get("/")] #[get("/")]
async fn index() -> impl Responder { 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")] #[post("/create")]
async fn create(data: web::Data<AppState>, pasta: web::Form<PastaFormData>) -> impl Responder { async fn create(data: web::Data<AppState>, pasta: web::Form<PastaFormData>) -> 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) { 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 innerPasta.expiration.as_str() { let expiration = match inner_pasta.expiration.as_str() {
"1min" => timenow + 60, "1min" => timenow + 60,
"10min" => timenow + 60 * 10, "10min" => timenow + 60 * 10,
"1hour" => timenow + 60 * 60, "1hour" => timenow + 60 * 60,
"24hour" => timenow + 60 * 60 * 24, "24hour" => timenow + 60 * 60 * 24,
"1week" => timenow + 60 * 60 * 24 * 7, "1week" => timenow + 60 * 60 * 24 * 7,
"never" => 0, "never" => 0,
_ => panic!("Unexpected expiration time!") _ => panic!("Unexpected expiration time!"),
}; };
let mut newPasta = Pasta { 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: innerPasta.content, content: inner_pasta.content,
created: timenow, created: timenow,
pasta_type,
expiration, expiration,
}; };
let id = newPasta.id; let id = new_pasta.id;
pastas.push(newPasta); pastas.push(new_pasta);
HttpResponse::Found().append_header(("Location", format!("/pasta/{}", to_animal_names(id)))).finish() HttpResponse::Found()
.append_header(("Location", format!("/pasta/{}", to_animal_names(id))))
.finish()
} }
#[get("/pasta/{id}")] #[get("/pasta/{id}")]
@ -85,14 +105,38 @@ async fn getpasta(data: web::Data<AppState>, id: web::Path<String>) -> HttpRespo
for pasta in pastas.iter() { for pasta in pastas.iter() {
if pasta.id == id { if pasta.id == id {
return HttpResponse::Found().content_type("text/html").body(PastaTemplate { pasta }.render().unwrap()); 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<AppState>, id: web::Path<String>) -> 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<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());
@ -118,7 +162,9 @@ async fn remove(data: web::Data<AppState>, id: web::Path<String>) -> HttpRespons
for (i, pasta) in pastas.iter().enumerate() { for (i, pasta) in pastas.iter().enumerate() {
if pasta.id == id { if pasta.id == id {
pastas.remove(i); pastas.remove(i);
return HttpResponse::Found().append_header(("Location", "/pastalist")).finish(); return HttpResponse::Found()
.append_header(("Location", "/pastalist"))
.finish();
} }
} }
@ -131,17 +177,38 @@ async fn list(data: web::Data<AppState>) -> HttpResponse {
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] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let args = Args::parse();
println!(
"{}",
format!("Listening on http://127.0.0.1:{}", args.port.to_string())
);
let data = web::Data::new(AppState { let data = web::Data::new(AppState {
pastas: Mutex::new(Vec::new()), pastas: Mutex::new(Vec::new()),
}); });
HttpServer::new(move || App::new().app_data(data.clone()).service(index).service(create).service(getpasta).service(getrawpasta).service(remove).service(list) HttpServer::new(move || {
).bind("127.0.0.1:8080")?.run().await 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<Pasta>) { fn remove_expired(pastas: &mut Vec<Pasta>) {
@ -150,7 +217,11 @@ fn remove_expired(pastas: &mut Vec<Pasta>) {
Err(_) => panic!("SystemTime before UNIX EPOCH!"), Err(_) => panic!("SystemTime before UNIX EPOCH!"),
} as i64; } as i64;
pastas.retain(|p| { pastas.retain(|p| p.expiration == 0 || p.expiration > timenow);
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()
} }

View file

@ -8,7 +8,8 @@ pub struct Pasta {
pub id: u64, pub id: u64,
pub content: String, pub content: String,
pub created: i64, pub created: i64,
pub expiration: i64 pub expiration: i64,
pub pasta_type: String
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View file

@ -3,22 +3,26 @@
<title>MicroBin</title> <title>MicroBin</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<body style="max-width: 720px; <link rel="stylesheet" href="/static/water.css">
</head>
<body style="
max-width: 720px;
margin: auto; margin: auto;
padding-left:0.5rem; padding-left:0.5rem;
padding-right:0.5rem; padding-right:0.5rem;
line-height: 1.5; line-height: 1.5;
font-size: 1.1em;"> font-size: 1.1em;
">
<br> <br>
<b style="margin-right: 0.5rem"> <b style="margin-right: 0.5rem">
MicroBin <i><span style="font-size:2.2rem; margin-right:1rem">μ</span></i> MicroBin
</b> </b>
|
<a href="/" style="margin-right: 0.5rem; margin-left: 0.5rem">New Pasta</a> <a href="/" style="margin-right: 0.5rem; margin-left: 0.5rem">New Pasta</a>
|
<a href="/pastalist" style="margin-right: 0.5rem; margin-left: 0.5rem">Pasta List</a> <a href="/pastalist" style="margin-right: 0.5rem; margin-left: 0.5rem">Pasta List</a>
|
<a href="https://github.com/szabodanika/microbin" style="margin-right: 0.5rem; margin-left: 0.5rem">GitHub</a> <a href="https://github.com/szabodanika/microbin" style="margin-right: 0.5rem; margin-left: 0.5rem">GitHub</a>
<hr> <hr>

View file

@ -1,6 +1,6 @@
{% include "header.html" %} {% include "header.html" %}
<form action="create" method="POST"> <form action="create" method="POST">
<input name="id" value="0" type="hidden"> <br>
<label for="expiration">Expiration</label><br> <label for="expiration">Expiration</label><br>
<select name="expiration" id="expiration"> <select name="expiration" id="expiration">
<optgroup label="Expire"> <optgroup label="Expire">
@ -15,9 +15,9 @@
<br> <br>
<label>Content</label> <label>Content</label>
<br> <br>
<textarea style="width: 100%; min-height: 100px" name="content"></textarea> <textarea style="width: 100%; min-height: 100px" name="content" autofocus></textarea>
<br>
<br> <br>
<input style="width: 100px; background-color: limegreen"; type="submit" value="Save"/> <input style="width: 100px; background-color: limegreen"; type="submit" value="Save"/>
<br>
</form> </form>
{% include "footer.html" %} {% include "footer.html" %}

View file

@ -1,6 +1,4 @@
{% include "header.html" %} {% include "header.html" %}
<a href="/rawpasta/{{pasta.idAsAnimals()}}">Raw Pasta</a> <a href="/raw/{{pasta.idAsAnimals()}}">Raw Pasta</a>
<pre style="background: lightgray; border: 1px black solid; padding: 0.5rem; overflow: auto"> <pre><code>{{pasta}}</code></pre>
{{pasta}}
</pre>
{% include "footer.html" %} {% include "footer.html" %}

View file

@ -6,8 +6,13 @@
No pastas yet. 😔 Create one <a href="/">here</a>. No pastas yet. 😔 Create one <a href="/">here</a>.
</p> </p>
{%- else %} {%- else %}
<table style="width: 100%" border="1"> <br>
<tr style="background: lightgrey"> <table style="width: 100%">
<thead>
<tr>
<th colspan="4">Pastas</th>
</tr>
<tr>
<th> <th>
Key Key
</th> </th>
@ -21,7 +26,10 @@
</th> </th>
</tr> </tr>
</thead>
<tbody>
{% for pasta in pastas %} {% for pasta in pastas %}
{% if pasta.pasta_type == "text" %}
<tr> <tr>
<td> <td>
<a href="/pasta/{{pasta.idAsAnimals()}}">{{pasta.idAsAnimals()}}</a> <a href="/pasta/{{pasta.idAsAnimals()}}">{{pasta.idAsAnimals()}}</a>
@ -33,10 +41,56 @@
{{pasta.expirationAsString()}} {{pasta.expirationAsString()}}
</td> </td>
<td> <td>
<a style="margin-right:1rem" href="/raw/{{pasta.idAsAnimals()}}">Raw</a>
<a href="/remove/{{pasta.idAsAnimals()}}">Remove</a> <a href="/remove/{{pasta.idAsAnimals()}}">Remove</a>
</td> </td>
</tr> </tr>
{%- endif %}
{% endfor %} {% endfor %}
</tbody>
</table> </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 %}
{% endfor %}
</tbody>
</table>
<br>
{%- endif %} {%- endif %}
{% include "footer.html" %} {% include "footer.html" %}