From 2ded816aa71d098d5348e422090a241508241b8b Mon Sep 17 00:00:00 2001 From: videogame hacker Date: Tue, 26 Jul 2022 04:59:58 +0100 Subject: [PATCH] Implement URL shortening feature set --- Cargo.lock | 6 ++- Cargo.toml | 2 + src/main.rs | 2 + src/redirects.rs | 75 ++++++++++++++++++++++++++++++++-- static/css/styles.css | 20 +++++++++ static/js/brief-html.js | 24 +++++++++++ static/js/shorten.js | 90 +++++++++++++++++++++++++++++++++++++++++ static/shorten.html | 43 +++++++++++++++++++- 8 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 static/js/brief-html.js create mode 100644 static/js/shorten.js diff --git a/Cargo.lock b/Cargo.lock index ab787a0..836ea39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,7 +87,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" name = "char_lt" version = "0.1.0" dependencies = [ + "dotenv", "serde", + "serde_json", "sqlx", "tokio", "warp", @@ -969,9 +971,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.74" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee2bb9cd061c5865d345bb02ca49fcef1391741b672b54a0bf7b679badec3142" +checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" dependencies = [ "itoa 1.0.1", "ryu", diff --git a/Cargo.toml b/Cargo.toml index 01b8f9c..5f6cfe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] +dotenv = "0.15.0" serde = { version = "1.0.133", features = ["derive"] } +serde_json = "1.0.82" sqlx = { version = "0.5.10", features = ["sqlite", "runtime-tokio-native-tls"] } tokio = { version = "1.15.0", features = ["full"] } warp = "0.3.2" diff --git a/src/main.rs b/src/main.rs index 5dc2723..4ffcbef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ mod redirects; #[tokio::main] async fn main() { + let _ = dotenv::dotenv(); + // TODO: Can we do some perfect hashing with the static dir? let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| String::from("./static")); let static_files = warp::fs::dir(static_dir.clone()); diff --git a/src/redirects.rs b/src/redirects.rs index 67bb4b0..a7cf7a8 100644 --- a/src/redirects.rs +++ b/src/redirects.rs @@ -1,9 +1,10 @@ use std::path::PathBuf; +use serde::Serialize; use sqlx::{Executor, SqlitePool}; use warp::{ filters::BoxedFilter, - hyper::{self}, + hyper::{self, StatusCode}, reply::Response, Filter, Rejection, Reply, }; @@ -20,6 +21,7 @@ async fn migrate(db: &SqlitePool) { let _ = sqlx::migrate!().run(db).await; } +#[derive(Serialize)] struct Redirect { path: String, target_location: String, @@ -40,15 +42,39 @@ struct AddRedirectForm { status_code: Option, } +fn auth_valid(authorization: &str) -> bool { + if let Some(token) = authorization.strip_prefix("Bearer ") { + if std::env::var("REDIRECTS_AUTH_KEY").ok().as_deref() == Some(token) { + return true; + } + } + + false +} + +fn json_error_response(message: &str, code: StatusCode) -> impl Reply { + warp::reply::with_status( + warp::reply::json(&serde_json::json!({ "error": message })), + code, + ) +} + async fn add_redirect( db: SqlitePool, add_redirect_form: AddRedirectForm, -) -> Result { + authorization: String, +) -> Result { + if !auth_valid(&authorization) { + return Ok( + json_error_response("Invalid authorization", StatusCode::UNAUTHORIZED).into_response(), + ); + } + async fn create_redirect(db: SqlitePool, redir: Redirect) -> sqlx::Result<()> { let mut conn = db.acquire().await?; let query = sqlx::query!( - "INSERT INTO redirects VALUES (?, ?, ?)", + "INSERT OR REPLACE INTO redirects VALUES (?, ?, ?)", redir.path, redir.target_location, redir.redirect_code @@ -58,8 +84,39 @@ async fn add_redirect( Ok(()) } + let path = add_redirect_form + .path + .unwrap_or_else(|| todo!("generate random string")); - Ok("hello") + let redir = Redirect { + path, + target_location: add_redirect_form.target, + redirect_code: add_redirect_form.status_code.map(i64::from).unwrap_or(302), + }; + + Ok(match create_redirect(db, redir).await { + Ok(_) => warp::reply::json(&serde_json::json!({"success": true})).into_response(), + Err(_) => { + json_error_response("Internal error", StatusCode::INTERNAL_SERVER_ERROR).into_response() + } + }) +} + +async fn list_redirects(db: SqlitePool, authorization: String) -> Result { + if !auth_valid(&authorization) { + return Err(warp::reject()); + } + + async fn get_redirects(db: SqlitePool) -> sqlx::Result> { + let mut conn = db.acquire().await?; + let query = sqlx::query_as!(Redirect, "SELECT * FROM redirects"); + query.fetch_all(&mut conn).await + } + + match get_redirects(db).await { + Ok(redirects) => Ok(warp::reply::json(&redirects)), + Err(_) => Err(warp::reject()), + } } async fn lookup_redirect(path: String, db: SqlitePool) -> Result { @@ -93,6 +150,14 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> { path }; + let redirects_route = warp::path!("redirects.json").and({ + let db = db.clone(); + warp::get() + .map(move || db.clone()) + .and(warp::header::("authorization")) + .and_then(list_redirects) + }); + let shorten_route = warp::path!("shorten") .and(warp::get().and(warp::fs::file(shorten_html))) // TODO: Templating to list existing shortlinks? .or({ @@ -100,6 +165,7 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> { warp::post() .map(move || db.clone()) .and(warp::body::form()) + .and(warp::header::("authorization")) .and_then(add_redirect) }); @@ -109,6 +175,7 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> { .and_then(lookup_redirect); shorten_route + .or(redirects_route) .or(lookup_route) .map(Reply::into_response) .boxed() diff --git a/static/css/styles.css b/static/css/styles.css index a86a78a..fc04b91 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -45,3 +45,23 @@ abbr { .lavender-webring-container { font-family: "Inter", sans-serif !important; } + +table { + width: 100%; + margin: 1em 0; +} + +th, +td { + text-align: start; +} + +form { + display: flex; + flex-direction: column; + width: 100%; +} + +form button { + margin-top: 0.5em; +} diff --git a/static/js/brief-html.js b/static/js/brief-html.js new file mode 100644 index 0000000..d459009 --- /dev/null +++ b/static/js/brief-html.js @@ -0,0 +1,24 @@ +export const q = (selector) => document.querySelector(selector); +export const t = (text) => document.createTextNode(text); +export const h = (tag, attributes, children) => { + if (!Array.isArray(children)) { + children = [children]; + } + + children = children.map((x) => { + if (x instanceof HTMLElement) return x; + return t(x); + }); + + const elem = document.createElement(tag); + Object.assign(elem, attributes); + elem.append(...children); + return elem; +}; +export const r = (f) => { + if (document.readyState === "complete") { + f(); + } else { + document.addEventListener("DOMContentLoaded", f); + } +}; diff --git a/static/js/shorten.js b/static/js/shorten.js new file mode 100644 index 0000000..90c1be8 --- /dev/null +++ b/static/js/shorten.js @@ -0,0 +1,90 @@ +import { r, q, h } from "./brief-html.js"; + +const populateRedirects = async (redirKey) => { + const body = q("main table tbody"); + body.innerHTML = "Loading..."; + + const redirects = await fetch("/redirects.json", { + headers: { authorization: `Bearer ${redirKey}` }, + }).then((r) => r.json()); + + body.innerHTML = ""; + + for (const redirect of redirects) { + body.appendChild( + h("tr", {}, [ + h("td", {}, h("pre", {}, [redirect.path])), + h( + "td", + {}, + h("a", { href: redirect.target_location }, [redirect.target_location]) + ), + h("td", {}, [redirect.redirect_code]), + ]) + ); + } +}; + +const setupForm = (redirKey) => { + const form = h("form", {}, [ + h("h2", {}, "create or replace"), + + h("label", { for: "path" }, ["local path (random if empty)"]), + h( + "input", + { + type: "text", + name: "path", + id: "path", + placeholder: "my-cool-link", + }, + [] + ), + + h("label", { for: "url" }, ["target url"]), + h( + "input", + { + type: "url", + name: "url", + id: "url", + placeholder: "https://…", + required: true, + }, + [] + ), + + h("button", {}, ["Create"]), + ]); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const data = new URLSearchParams(); + const path = form.querySelector("#path").value; + if (path) data.append("path", path); + data.append("target", form.querySelector("#url").value); + + const response = await fetch("/shorten", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Bearer ${redirKey}`, + }, + body: data, + }).then((r) => r.json()); + + if (response.error) alert(response.error); + + window.location.reload(); + }); + + q("main").appendChild(form); +}; + +r(async () => { + const redirKey = window.localStorage.getItem("redirect-key"); + if (redirKey == null) return; + + await Promise.all([populateRedirects(redirKey), setupForm(redirKey)]); +}); diff --git a/static/shorten.html b/static/shorten.html index 2a167cf..2399f7e 100644 --- a/static/shorten.html +++ b/static/shorten.html @@ -1,3 +1,42 @@ - -

TODO! sorry :(

+ + + + + shorten - char.lt + + + + + + + + +
+
+

redirects

+

this is where you can manage the redirects for the site.

+ + + + + + + + + + + + + + + +
pathurlcode
+ You are not authorised to fetch the list of redirects. +
+
+
+ + + +