Implement URL shortening feature set
This commit is contained in:
parent
3a477f6da5
commit
2ded816aa7
8 changed files with 254 additions and 8 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
24
static/js/brief-html.js
Normal 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
90
static/js/shorten.js
Normal 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)]);
|
||||
});
|
|
@ -1,3 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<p>TODO! sorry :(</p>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<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>
|
||||
|
|
Loading…
Reference in a new issue