commit 6e3d6114ff75825b43b953ee8833baf92796d0d2 Author: LordMZTE Date: Thu Jun 24 15:57:11 2021 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bace3f3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "brevo" +version = "0.1.0" +authors = ["LordMZTE "] +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"] diff --git a/assets/index.html.tera b/assets/index.html.tera new file mode 100644 index 0000000..56484e6 --- /dev/null +++ b/assets/index.html.tera @@ -0,0 +1,50 @@ + + + + + + + brevo + + + + +
+
+

Shorten URL

+
+ + +
+
+
+ + + diff --git a/assets/success.html.tera b/assets/success.html.tera new file mode 100644 index 0000000..e0bf130 --- /dev/null +++ b/assets/success.html.tera @@ -0,0 +1,29 @@ + + + + + + + brevo + + + + +

Success! Your link is {{ link }} + + + diff --git a/brevo.service b/brevo.service new file mode 100644 index 0000000..12f8641 --- /dev/null +++ b/brevo.service @@ -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 diff --git a/defaultconfig.toml b/defaultconfig.toml new file mode 100644 index 0000000..51689a7 --- /dev/null +++ b/defaultconfig.toml @@ -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/" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1059111 --- /dev/null +++ b/rustfmt.toml @@ -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 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..44f8182 --- /dev/null +++ b/src/config.rs @@ -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, +} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..e18861a --- /dev/null +++ b/src/handlers.rs @@ -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) -> Result, 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) -> Result, 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::(0) + .map_err(sql_reject)? + .parse::() + .map_err(|e| reject::custom(BrevoReject::UriParseError(e)))?, + ))), + None => Err(reject::not_found()), + } +} + +pub async fn submit(form_data: SubmitForm, brevo: Arc) -> Result, 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::(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) -> 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 {} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b9803f4 --- /dev/null +++ b/src/main.rs @@ -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::() + .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 { + let data = tokio::fs::read(path).await?; + Ok(toml::from_slice::(&data)?) +} + +pub struct Brevo { + pub config: Config, + pub pool: MySqlPool, + pub tera: Tera, + pub rng: Mutex, +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..4ff530c --- /dev/null +++ b/src/util.rs @@ -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) -> Rejection { + reject::custom(BrevoReject::RenderFail) +} + +pub fn make_link(brevo: Arc, id: &str) -> Result { + brevo + .config + .base_url + .join(id) + .map(String::from) + .map_err(|e| reject::custom(BrevoReject::UrlParseError(e))) +} + +pub async fn gen_id(brevo: Arc) -> String { + "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + .chars() + .choose_multiple(&mut *brevo.rng.lock().await, brevo.config.link_len as usize) + .into_iter() + .collect() +} diff --git a/testenv/docker-compose.yml b/testenv/docker-compose.yml new file mode 100644 index 0000000..66021bb --- /dev/null +++ b/testenv/docker-compose.yml @@ -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