Implement URL shortening feature set

main
Charlotte Som 2022-07-26 04:59:58 +01:00
parent 3a477f6da5
commit 2ded816aa7
8 changed files with 254 additions and 8 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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());

View File

@ -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<u16>,
}
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<impl Reply, Rejection> {
authorization: String,
) -> Result<Response, Rejection> {
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<impl Reply, Rejection> {
if !auth_valid(&authorization) {
return Err(warp::reject());
}
async fn get_redirects(db: SqlitePool) -> sqlx::Result<Vec<Redirect>> {
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<impl Reply, Rejection> {
@ -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::<String>("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::<String>("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()

View File

@ -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;
}

24
static/js/brief-html.js Normal file
View File

@ -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);
}
};

90
static/js/shorten.js Normal file
View File

@ -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)]);
});

View File

@ -1,3 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<p>TODO! sorry :(</p>
<title>shorten - char.lt</title>
<link rel="stylesheet" href="/css/layout.css" />
<link rel="stylesheet" href="/css/styles.css" />
<link rel="stylesheet" href="/inter/inter.css" />
</head>
<body>
<div class="page-wrapper">
<main>
<h1>redirects</h1>
<p>this is where you can manage the redirects for the site.</p>
<table>
<thead>
<tr>
<th>path</th>
<th>url</th>
<th>code</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="3">
You are not authorised to fetch the list of redirects.
</td>
</tr>
</tbody>
</table>
</main>
</div>
<script type="module" src="/js/shorten.js"></script>
</body>
</html>