Implement URL shortening feature set
parent
3a477f6da5
commit
2ded816aa7
|
@ -87,7 +87,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
name = "char_lt"
|
name = "char_lt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dotenv",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"warp",
|
"warp",
|
||||||
|
@ -969,9 +971,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.74"
|
version = "1.0.82"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee2bb9cd061c5865d345bb02ca49fcef1391741b672b54a0bf7b679badec3142"
|
checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa 1.0.1",
|
"itoa 1.0.1",
|
||||||
"ryu",
|
"ryu",
|
||||||
|
|
|
@ -4,7 +4,9 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
dotenv = "0.15.0"
|
||||||
serde = { version = "1.0.133", features = ["derive"] }
|
serde = { version = "1.0.133", features = ["derive"] }
|
||||||
|
serde_json = "1.0.82"
|
||||||
sqlx = { version = "0.5.10", features = ["sqlite", "runtime-tokio-native-tls"] }
|
sqlx = { version = "0.5.10", features = ["sqlite", "runtime-tokio-native-tls"] }
|
||||||
tokio = { version = "1.15.0", features = ["full"] }
|
tokio = { version = "1.15.0", features = ["full"] }
|
||||||
warp = "0.3.2"
|
warp = "0.3.2"
|
||||||
|
|
|
@ -5,6 +5,8 @@ mod redirects;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
let _ = dotenv::dotenv();
|
||||||
|
|
||||||
// TODO: Can we do some perfect hashing with the static dir?
|
// 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_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| String::from("./static"));
|
||||||
let static_files = warp::fs::dir(static_dir.clone());
|
let static_files = warp::fs::dir(static_dir.clone());
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::{Executor, SqlitePool};
|
use sqlx::{Executor, SqlitePool};
|
||||||
use warp::{
|
use warp::{
|
||||||
filters::BoxedFilter,
|
filters::BoxedFilter,
|
||||||
hyper::{self},
|
hyper::{self, StatusCode},
|
||||||
reply::Response,
|
reply::Response,
|
||||||
Filter, Rejection, Reply,
|
Filter, Rejection, Reply,
|
||||||
};
|
};
|
||||||
|
@ -20,6 +21,7 @@ async fn migrate(db: &SqlitePool) {
|
||||||
let _ = sqlx::migrate!().run(db).await;
|
let _ = sqlx::migrate!().run(db).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
struct Redirect {
|
struct Redirect {
|
||||||
path: String,
|
path: String,
|
||||||
target_location: String,
|
target_location: String,
|
||||||
|
@ -40,15 +42,39 @@ struct AddRedirectForm {
|
||||||
status_code: Option<u16>,
|
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(
|
async fn add_redirect(
|
||||||
db: SqlitePool,
|
db: SqlitePool,
|
||||||
add_redirect_form: AddRedirectForm,
|
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<()> {
|
async fn create_redirect(db: SqlitePool, redir: Redirect) -> sqlx::Result<()> {
|
||||||
let mut conn = db.acquire().await?;
|
let mut conn = db.acquire().await?;
|
||||||
|
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
"INSERT INTO redirects VALUES (?, ?, ?)",
|
"INSERT OR REPLACE INTO redirects VALUES (?, ?, ?)",
|
||||||
redir.path,
|
redir.path,
|
||||||
redir.target_location,
|
redir.target_location,
|
||||||
redir.redirect_code
|
redir.redirect_code
|
||||||
|
@ -58,8 +84,39 @@ async fn add_redirect(
|
||||||
|
|
||||||
Ok(())
|
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> {
|
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
|
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")
|
let shorten_route = warp::path!("shorten")
|
||||||
.and(warp::get().and(warp::fs::file(shorten_html))) // TODO: Templating to list existing shortlinks?
|
.and(warp::get().and(warp::fs::file(shorten_html))) // TODO: Templating to list existing shortlinks?
|
||||||
.or({
|
.or({
|
||||||
|
@ -100,6 +165,7 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> {
|
||||||
warp::post()
|
warp::post()
|
||||||
.map(move || db.clone())
|
.map(move || db.clone())
|
||||||
.and(warp::body::form())
|
.and(warp::body::form())
|
||||||
|
.and(warp::header::<String>("authorization"))
|
||||||
.and_then(add_redirect)
|
.and_then(add_redirect)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,6 +175,7 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> {
|
||||||
.and_then(lookup_redirect);
|
.and_then(lookup_redirect);
|
||||||
|
|
||||||
shorten_route
|
shorten_route
|
||||||
|
.or(redirects_route)
|
||||||
.or(lookup_route)
|
.or(lookup_route)
|
||||||
.map(Reply::into_response)
|
.map(Reply::into_response)
|
||||||
.boxed()
|
.boxed()
|
||||||
|
|
|
@ -45,3 +45,23 @@ abbr {
|
||||||
.lavender-webring-container {
|
.lavender-webring-container {
|
||||||
font-family: "Inter", sans-serif !important;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
|
@ -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>
|
<!DOCTYPE html>
|
||||||
<meta charset="utf-8" />
|
<html>
|
||||||
<p>TODO! sorry :(</p>
|
<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 New Issue