This commit is contained in:
LordMZTE 2021-06-24 15:57:11 +02:00
commit 6e3d6114ff
12 changed files with 438 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

34
Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "brevo"
version = "0.1.0"
authors = ["LordMZTE <lord@mzte.de>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.41"
clap = "2.33.3"
env_logger = "0.8.4"
log = "0.4.14"
rand = "0.8.4"
structopt = "0.3.21"
tera = "1.11.0"
toml = "0.5.8"
warp = "0.3.1"
[dependencies.serde]
version = "1.0.126"
features = ["derive"]
[dependencies.sqlx]
version = "0.5.5"
features = ["mysql", "runtime-tokio-rustls"]
[dependencies.tokio]
version = "1.7.1"
features = ["macros", "rt-multi-thread", "fs"]
[dependencies.url]
version = "2.2.2"
features = ["serde"]

50
assets/index.html.tera Normal file
View file

@ -0,0 +1,50 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>brevo</title>
<style type="text/css" media="screen">
body {
background-color: #282a36;
color: #f8f8f2;
font-family: monospace;
}
#content {
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
display: flex;
}
#url_input {
width: 80vw;
height: 30px;
background-color: #282a36;
color: #f8f8f2;
}
#submit_button {
height: 30px;
background-color: #50fa7b;
color: #282a36;
}
</style>
</head>
<body>
<div id="content">
<div>
<h1>Shorten URL</h1>
<form action="" method="POST" accept-charset="utf-8">
<input type="text" name="url" id="url_input" placeholder="enter URL here...">
<button type="submit" id="submit_button">Create!</button>
</form>
</div>
</div>
</body>
</html>

29
assets/success.html.tera Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>brevo</title>
<style type="text/css" media="screen">
body {
background-color: #282a36;
color: #f8f8f2;
font-family: monospace;
}
a {
color: #ff79c6;
}
a:visited {
color: #bd93f9;
}
</style>
</head>
<body>
<h1>Success! Your link is <a href="{{ link }}">{{ link }}</a>
</body>
</html>

12
brevo.service Normal file
View file

@ -0,0 +1,12 @@
# Systemd service for brevo
[Unit]
Description=Link Shortener written in Rust
[Service]
Environment=RUST_LOG=info
ExecStart=/usr/bin/brevo
ExecReload=/usr/bin/brevo
[Install]
WantedBy=default.target

5
defaultconfig.toml Normal file
View file

@ -0,0 +1,5 @@
database_url = "mysql://brevo:brevo@127.0.0.1:3306/brevo"
database = "brevo"
bind_addr = "127.0.0.1:3001"
link_len = 6
base_url = "http://127.0.0.1:3001/"

12
rustfmt.toml Normal file
View file

@ -0,0 +1,12 @@
unstable_features = true
binop_separator = "Back"
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
match_block_trailing_comma = true
merge_imports = true
normalize_comments = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

13
src/config.rs Normal file
View file

@ -0,0 +1,13 @@
use std::net::SocketAddr;
use serde::Deserialize;
use url::Url;
#[derive(Deserialize)]
pub struct Config {
pub database_url: String,
pub bind_addr: SocketAddr,
pub database: String,
pub link_len: u8,
pub base_url: Url,
}

142
src/handlers.rs Normal file
View file

@ -0,0 +1,142 @@
use crate::util::gen_id;
use crate::util::make_link;
use crate::util::render_reject;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::sync::Arc;
use tera::Context;
use url::Url;
use warp::http::uri::InvalidUri;
use warp::redirect;
use warp::{
http::Uri,
reject::{self, Reject},
reply, Rejection, Reply,
};
use crate::{sqlargs, util::sql_reject, Brevo};
pub async fn index(brevo: Arc<Brevo>) -> Result<Box<dyn Reply>, Rejection> {
let rendered = brevo.tera.render("index.html", &Context::new());
match rendered {
Err(_) => Err(reject::custom(BrevoReject::RenderFail)),
Ok(r) => Ok(Box::new(reply::html(r))),
}
}
pub async fn shortened(id: String, brevo: Arc<Brevo>) -> Result<Box<dyn Reply>, Rejection> {
make_table(Arc::clone(&brevo)).await?;
let url = sqlx::query_with(
"
SELECT url FROM urls
WHERE id = ?
",
sqlargs![&id],
)
.fetch_optional(&brevo.pool)
.await
.map_err(sql_reject)?;
match url {
Some(u) => Ok(Box::new(redirect::permanent(
u.try_get::<String, _>(0)
.map_err(sql_reject)?
.parse::<Uri>()
.map_err(|e| reject::custom(BrevoReject::UriParseError(e)))?,
))),
None => Err(reject::not_found()),
}
}
pub async fn submit(form_data: SubmitForm, brevo: Arc<Brevo>) -> Result<Box<dyn Reply>, Rejection> {
make_table(Arc::clone(&brevo)).await?;
let url = form_data.url.as_str();
let id = sqlx::query_with(
"
SELECT id FROM urls
WHERE url = ?
",
sqlargs![url],
)
.fetch_optional(&brevo.pool)
.await
.map_err(sql_reject)?;
if let Some(id) = id {
let id = id.try_get::<String, _>(0).map_err(sql_reject)?;
let rendered = brevo
.tera
.render(
"success.html",
&Context::from_serialize(SuccessContent {
link: make_link(Arc::clone(&brevo), &id)?,
})
.map_err(render_reject)?,
)
.map_err(render_reject)?;
Ok(Box::new(reply::html(rendered)))
} else {
let id = gen_id(Arc::clone(&brevo)).await;
sqlx::query_with(
"
INSERT INTO urls ( id, url ) VALUES
( ?, ? )
",
sqlargs![&id, url],
)
.execute(&brevo.pool)
.await
.map_err(sql_reject)?;
let rendered = brevo
.tera
.render(
"success.html",
&Context::from_serialize(SuccessContent {
link: make_link(Arc::clone(&brevo), &id)?,
})
.map_err(render_reject)?,
)
.map_err(render_reject)?;
Ok(Box::new(reply::html(rendered)))
}
}
async fn make_table(brevo: Arc<Brevo>) -> Result<(), Rejection> {
// can't parameterize VARCHAR len, but this should be safe
sqlx::query(&format!(
"
CREATE TABLE IF NOT EXISTS urls (
id VARCHAR({}) PRIMARY KEY,
url TEXT NOT NULL UNIQUE
)
",
brevo.config.link_len
))
.execute(&brevo.pool)
.await
.map_err(sql_reject)?;
Ok(())
}
#[derive(Serialize)]
struct SuccessContent {
link: String,
}
#[derive(Deserialize)]
pub struct SubmitForm {
url: Url,
}
// TODO: implement reject handerls
#[derive(Debug)]
pub enum BrevoReject {
RenderFail,
SqlError(sqlx::Error),
UrlParseError(url::ParseError),
UriParseError(InvalidUri),
}
impl Reject for BrevoReject {}

79
src/main.rs Normal file
View file

@ -0,0 +1,79 @@
use tokio::sync::Mutex;
use crate::config::Config;
use anyhow::Context;
use rand::{rngs::StdRng, SeedableRng};
use sqlx::MySqlPool;
use std::sync::Arc;
use structopt::StructOpt;
use tera::Tera;
use warp::Filter;
mod config;
mod handlers;
mod util;
#[derive(StructOpt)]
struct Opt {
#[structopt(index = 1, help = "config file to use")]
config: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
let opt = Opt::from_args();
let config = load_config(&opt.config)
.await
.context("error loading config")?;
let pool = MySqlPool::connect(&config.database_url)
.await
.context("error creating mysql pool")?;
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("index.html", include_str!("../assets/index.html.tera")),
("success.html", include_str!("../assets/success.html.tera")),
])
.context("error adding templates to tera")?;
let brevo = Arc::new(Brevo {
config,
pool,
tera,
rng: Mutex::new(StdRng::from_rng(rand::thread_rng())?),
});
let brevo_idx = Arc::clone(&brevo);
let brevo_submit = Arc::clone(&brevo);
let brevo_srv = Arc::clone(&brevo);
let routes = warp::get()
.and(
warp::path::end()
.and_then(move || handlers::index(Arc::clone(&brevo_idx)))
.or(warp::path::param::<String>()
.and(warp::path::end())
.and_then(move |id| handlers::shortened(id, Arc::clone(&brevo)))),
)
.or(warp::post()
.and(warp::body::content_length_limit(1024 * 16))
.and(warp::body::form())
.and_then(move |form_data| handlers::submit(form_data, Arc::clone(&brevo_submit))));
warp::serve(routes).run(brevo_srv.config.bind_addr).await;
Ok(())
}
async fn load_config(path: &str) -> anyhow::Result<Config> {
let data = tokio::fs::read(path).await?;
Ok(toml::from_slice::<Config>(&data)?)
}
pub struct Brevo {
pub config: Config,
pub pool: MySqlPool,
pub tera: Tera,
pub rng: Mutex<StdRng>,
}

44
src/util.rs Normal file
View file

@ -0,0 +1,44 @@
use std::sync::Arc;
use crate::handlers::BrevoReject;
use crate::Brevo;
use rand::seq::IteratorRandom;
use warp::reject;
use warp::Rejection;
#[macro_export]
macro_rules! sqlargs {
($($item:expr),+) => {{
use sqlx::Arguments;
let mut args = sqlx::mysql::MySqlArguments::default();
$(
args.add($item);
)*
args
}};
}
pub fn sql_reject(e: sqlx::Error) -> Rejection {
reject::custom(BrevoReject::SqlError(e))
}
pub fn render_reject<T>(_: T) -> Rejection {
reject::custom(BrevoReject::RenderFail)
}
pub fn make_link(brevo: Arc<Brevo>, id: &str) -> Result<String, Rejection> {
brevo
.config
.base_url
.join(id)
.map(String::from)
.map_err(|e| reject::custom(BrevoReject::UrlParseError(e)))
}
pub async fn gen_id(brevo: Arc<Brevo>) -> String {
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
.chars()
.choose_multiple(&mut *brevo.rng.lock().await, brevo.config.link_len as usize)
.into_iter()
.collect()
}

View file

@ -0,0 +1,16 @@
version: "3.1"
services:
db:
image: mariadb:10.6.2
ports:
- 3306:3306
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_USER: brevo
MARIADB_PASSWORD: brevo
MARIADB_DATABASE: brevo
adminer:
image: adminer
ports:
- 8080:8080