Rework design & implement link shorten page

main
Charlotte Som 2023-07-02 17:05:02 +01:00
parent 46a645973d
commit 94f645b9a5
11 changed files with 839 additions and 458 deletions

621
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,9 @@ edition = "2021"
[dependencies] [dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"
serde = { version = "1.0.133", features = ["derive"] } rand = "0.8.5"
serde_json = "1.0.82" serde = { version = "1.0.164", features = ["derive"] }
sqlx = { version = "0.5.10", features = ["sqlite", "runtime-tokio-rustls"] } serde_json = "1.0.99"
tokio = { version = "1.15.0", features = ["full"] } sqlx = { version = "0.6.3", features = ["sqlite", "runtime-tokio-rustls"] }
warp = "0.3.2" tokio = { version = "1.29.1", features = ["full"] }
warp = "0.3.5"

View File

@ -42,6 +42,11 @@ struct AddRedirectForm {
status_code: Option<u16>, status_code: Option<u16>,
} }
#[derive(serde::Deserialize)]
struct DeleteRedirectForm {
path: String,
}
fn auth_valid(authorization: &str) -> bool { fn auth_valid(authorization: &str) -> bool {
if let Some(token) = authorization.strip_prefix("Bearer ") { if let Some(token) = authorization.strip_prefix("Bearer ") {
if std::env::var("REDIRECTS_AUTH_KEY").ok().as_deref() == Some(token) { if std::env::var("REDIRECTS_AUTH_KEY").ok().as_deref() == Some(token) {
@ -64,12 +69,6 @@ async fn add_redirect(
add_redirect_form: AddRedirectForm, add_redirect_form: AddRedirectForm,
authorization: String, authorization: String,
) -> Result<Response, Rejection> { ) -> 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?;
@ -84,9 +83,29 @@ async fn add_redirect(
Ok(()) Ok(())
} }
let path = add_redirect_form
.path if !auth_valid(&authorization) {
.unwrap_or_else(|| todo!("generate random string")); return Ok(
json_error_response("Invalid authorization", StatusCode::UNAUTHORIZED).into_response(),
);
}
let path = add_redirect_form.path.unwrap_or_else(|| {
use rand::{thread_rng, Rng};
const LEN: usize = 16;
const CHARSET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let mut rng = thread_rng();
let mut random_string = String::with_capacity(LEN);
for _ in 0..LEN {
let idx = rng.gen_range(0..CHARSET.len());
let c: &str = &CHARSET[idx..=idx + 1]; // we know the charset is ASCII so this is okay
random_string.push_str(c);
}
random_string
});
let redir = Redirect { let redir = Redirect {
path, path,
@ -94,12 +113,42 @@ async fn add_redirect(
redirect_code: add_redirect_form.status_code.map(i64::from).unwrap_or(302), redirect_code: add_redirect_form.status_code.map(i64::from).unwrap_or(302),
}; };
Ok(match create_redirect(db, redir).await { if let Err(_e) = create_redirect(db, redir).await {
Ok(_) => warp::reply::json(&serde_json::json!({"success": true})).into_response(), return Ok(
Err(_) => { json_error_response("Internal error", StatusCode::INTERNAL_SERVER_ERROR)
json_error_response("Internal error", StatusCode::INTERNAL_SERVER_ERROR).into_response() .into_response(),
} );
}) }
Ok(warp::reply::json(&serde_json::json!({"success": true})).into_response())
}
async fn delete_redirect(
db: SqlitePool,
form: DeleteRedirectForm,
authorization: String,
) -> Result<Response, Rejection> {
async fn delete_redirect(db: SqlitePool, redir_path: &str) -> sqlx::Result<()> {
let mut conn = db.acquire().await?;
let query = sqlx::query!("DELETE FROM redirects WHERE path = ?", redir_path);
let _ = conn.execute(query).await?;
Ok(())
}
if !auth_valid(&authorization) {
return Ok(
json_error_response("Invalid authorization", StatusCode::UNAUTHORIZED).into_response(),
);
}
if let Err(_e) = delete_redirect(db, &form.path).await {
return Ok(
json_error_response("Internal error", StatusCode::INTERNAL_SERVER_ERROR)
.into_response(),
);
}
Ok(warp::reply::json(&serde_json::json!({"success": true})).into_response())
} }
async fn list_redirects(db: SqlitePool, authorization: String) -> Result<impl Reply, Rejection> { async fn list_redirects(db: SqlitePool, authorization: String) -> Result<impl Reply, Rejection> {
@ -167,6 +216,14 @@ pub async fn routes(static_dir: &str) -> BoxedFilter<(Response,)> {
.and(warp::body::form()) .and(warp::body::form())
.and(warp::header::<String>("authorization")) .and(warp::header::<String>("authorization"))
.and_then(add_redirect) .and_then(add_redirect)
})
.or({
let db = db.clone();
warp::delete()
.map(move || db.clone())
.and(warp::body::form())
.and(warp::header::<String>("authorization"))
.and_then(delete_redirect)
}); });
let lookup_route = warp::path::param() let lookup_route = warp::path::param()

140
static/css/aesthetic.css Normal file
View File

@ -0,0 +1,140 @@
:root {
--bg: #121212;
--fg: white;
--accent: rgb(255, 167, 248);
--font: "Inter", sans-serif;
--font-mono: ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Courier New",
monospace;
}
html {
background-color: var(--bg);
color: var(--fg);
}
a {
color: var(--accent);
}
body {
font-family: var(--font);
font-size: 1.125em;
}
pre,
code,
kbd {
font-family: var(--font-mono);
}
ul,
ol {
margin: 0;
padding: 0;
}
ul li,
ol li {
margin-left: 1em;
}
.inline-list {
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.inline-list li {
display: block;
margin: 0;
padding-inline-end: 0.5ch;
}
.inline-list li + li {
border-inline-start: 2px solid white;
padding-inline-start: 0.5ch;
}
abbr {
text-decoration: none;
}
.lavender-webring-container {
font-family: "Inter", sans-serif !important;
}
th,
td {
text-align: start;
padding: 0.5em;
}
form {
display: flex;
flex-direction: column;
width: 100%;
}
form button {
margin-top: 0.5em;
}
input {
font-family: sans-serif;
}
button {
display: inline-block;
font-family: var(--font);
font-size: 1em;
border: 1px solid var(--accent);
border-radius: 6px;
font-weight: 500;
background-color: transparent;
padding: 0.5rem 1.5rem;
color: var(--accent);
cursor: pointer;
text-align: center;
}
button:hover {
background-color: var(--accent);
color: var(--bg);
}
button.danger {
--accent: rgb(255, 111, 111);
}
input[type="text"],
input[type="url"] {
font-family: var(--font);
margin: 0.25em 0;
margin-bottom: 0.35em;
padding: 0.5em 0.5em;
background-color: #100f11;
border: 1px solid #e7ebf080;
border-radius: 6px;
color: var(--fg);
outline: none;
}
input[type="text"]:focus,
input[type="url"]:focus {
border-color: var(--accent);
}
h1,
h2,
h3,
label,
th,
button {
text-transform: lowercase;
}

View File

@ -16,30 +16,33 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 60ch; max-width: 80ch;
width: 60ch; width: 80ch;
margin: 0 auto; margin: 0 auto;
} }
.about-me { #about-me {
padding: 1em; padding: 1em;
border: 2px solid white; border: 2px solid white;
border-radius: 1em; border-radius: 1em;
margin: 1em 0;
} }
header { header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding-bottom: 0.5em;
border-bottom: 1px dashed #444;
margin-bottom: 0.5em;
} }
header aside { header > * {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 50%;
} }
main { main {
@ -52,25 +55,29 @@ footer {
} }
.hero-image { .hero-image {
height: 10rem; height: 8rem;
border-radius: 6px; border-radius: 8px;
} }
section + section { section + section {
margin-top: 0.5em; margin-top: 0.5em;
} }
h1, h1 {
h2,
h3 {
margin: 0; margin: 0;
margin-bottom: 1em; margin-bottom: 0.5em;
font-size: 2.2rem;
}
h2 {
margin: 0;
font-size: 1em;
} }
p { p {
margin: 0.5em 0; margin: 0.5em 0;
} }
.webring-container { p + p {
margin-top: 1em; margin-top: 1em;
} }

View File

@ -0,0 +1,28 @@
html,
body {
margin: 0;
padding: 0;
}
.page-wrapper {
padding: 1em;
}
h1,
h2,
h3 {
margin: 0;
margin-bottom: 1em;
}
table {
width: 100%;
margin: 1em 0;
}
table td.clipped {
overflow-x: clip;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 40ch;
}

View File

@ -1,75 +0,0 @@
html {
background-color: #121212;
color: white;
}
a {
color: rgb(255, 167, 248);
}
body {
font-family: "Inter", sans-serif;
font-size: 1.125em;
}
code {
font-family: "Courier New", Courier, monospace;
}
.inline-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.inline-list li {
display: block;
padding-inline-end: 0.5ch;
}
.inline-list li + li {
border-inline-start: 2px solid white;
padding-inline-start: 0.5ch;
}
abbr {
text-decoration: none;
}
.lavender-webring-container {
font-family: "Inter", sans-serif !important;
}
table {
width: 100%;
margin: 1em 0;
}
th,
td {
text-align: start;
padding: 0.5em;
}
form {
display: flex;
flex-direction: column;
width: 100%;
}
form button {
margin-top: 0.5em;
}
table td.clipped {
overflow-x: clip;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 30ch;
}

View File

@ -10,8 +10,8 @@
<title>charlotte ✨</title> <title>charlotte ✨</title>
<link rel="stylesheet" href="/css/layout.css" /> <link rel="stylesheet" href="/css/main-layout.css" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/aesthetic.css" />
<link rel="stylesheet" href="/inter/inter.css" /> <link rel="stylesheet" href="/inter/inter.css" />
@ -39,7 +39,7 @@
<body> <body>
<div class="page-wrapper"> <div class="page-wrapper">
<article class="about-me"> <article id="about-me">
<header> <header>
<aside> <aside>
<h1><abbr title="charlotte athena som">charlotte ✨</abbr></h1> <h1><abbr title="charlotte athena som">charlotte ✨</abbr></h1>
@ -47,8 +47,8 @@
<ul id="traits" class="inline-list"> <ul id="traits" class="inline-list">
<li id="age">22</li> <li id="age">22</li>
<li>trans</li> <li>trans</li>
<li>catgirl bunnygirl enby</li>
<li>lesbian</li> <li>lesbian</li>
<li>bunnygirl enby</li>
<li>anarchist</li> <li>anarchist</li>
<li>communist</li> <li>communist</li>
</ul> </ul>
@ -63,19 +63,23 @@
<main> <main>
<p> <p>
welcome to <code>char.lt</code>: the domain, is, like, my name!! hey, welcome to <code>char.lt</code>: the domain, is, like, my
name!!
</p> </p>
<p> <p>
hey, i'm charlotte! i'm transfem and non-binary and in my early 20s i'm charlotte! i'm transfem and non-binary and in my early 20s :)
:)
</p> </p>
<p> <p>i do multimedia projects with computers! that means:</p>
i do multimedia with computers, which means i mostly do electronic
music, software, procedural animations and graphics, and other silly <ul id="activities">
art projects! <li>electronic music</li>
</p> <li>software</li>
<li>procedural animations</li>
<li>visual effects</li>
<li>other silly art projects!</li>
</ul>
<p> <p>
i am the founder + director of a fledgling digital studio, i am the founder + director of a fledgling digital studio,
@ -83,21 +87,18 @@
</p> </p>
<section> <section>
my pronouns are: <h2>my pronouns are:</h2>
<ul id="pronouns" class="inline-list"> <ul id="pronouns" class="inline-list">
<li> <li>
<a href="https://pronoun.is/bun/bun/buns/buns/bunself"> <a href="https://pronouns.within.lgbt/bun">bun/buns</a>
bun/buns
</a>
</li> </li>
<li><a href="https://pronoun.is/they">they/them</a></li> <li><a href="https://pronouns.within.lgbt/she">she/her</a></li>
<li><a href="https://pronoun.is/she">she/her</a></li>
</ul> </ul>
</section> </section>
<section> <section>
talk to me in: <h2>talk to me in:</h2>
<ul id="languages" class="inline-list"> <ul id="languages" class="inline-list">
<li>english</li> <li>english</li>
@ -109,23 +110,21 @@
</section> </section>
<section> <section>
<ul id="links" class="inline-list"> <h2>find me around the web:</h2>
<ul id="socials" class="inline-list">
<li> <li>
<a rel="me" href="https://som.codes">som.codes</a> <a rel="me" href="https://trans.enby.town/charlotte">
</li>
<li>
<a rel="me" href="https://hackery.site">hackery.site</a>
</li>
<li>
<a rel="me" href="https://trans.enby.town/bun">
trans.enby.town trans.enby.town
</a> </a>
(fediverse)
</li> </li>
<li> <li>
<a rel="me" href="https://github.com/char">github</a> <a rel="me" href="https://pet.bun.how/"> pet.bun.how </a>
(bluesky)
</li> </li>
<li> <li>
<a rel="me" href="https://twitter.com/bhop_art"> twitter </a> <a rel="me" href="https://twitter.com/char_bun">twitter</a>
</li> </li>
<li> <li>
<a rel="me" href="https://last.fm/user/half-kh-hacker"> <a rel="me" href="https://last.fm/user/half-kh-hacker">
@ -137,6 +136,15 @@
soundcloud soundcloud
</a> </a>
</li> </li>
</ul>
</section>
<section>
<h2>find my software:</h2>
<ul id="forges" class="inline-list">
<li>
<a rel="me" href="https://github.com/char">github</a>
</li>
<li> <li>
<a rel="me" href="https://git.lavender.software/charlotte"> <a rel="me" href="https://git.lavender.software/charlotte">
git.lavender.software git.lavender.software
@ -144,12 +152,20 @@
</li> </li>
</ul> </ul>
</section> </section>
<section>
<h2>blogs:</h2>
<ul id="websites" class="inline-list">
<li><a href="https://som.codes/">som.codes</a></li>
<li><a href="https://hackery.site">hackery.site</a></li>
</ul>
</section>
</main> </main>
<footer>a site by <a href="https://som.codes">charlotte som</a></footer> <footer>a site by <a href="https://som.codes">charlotte som</a></footer>
</article> </article>
<article class="webring-container"> <article id="webring-container">
<script <script
src="https://lavender.software/webring/webring-0.2.0.js" src="https://lavender.software/webring/webring-0.2.0.js"
data-site-id="charlotte" data-site-id="charlotte"

View File

@ -1,24 +0,0 @@
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);
}
};

View File

@ -1,65 +1,70 @@
import { r, q, h } from "./brief-html.js"; const q = (selector, elem = document) => elem.querySelector(selector);
const t = (templateSelector, element) =>
const populateRedirects = async (redirKey) => { q(templateSelector).content.cloneNode(true).querySelector(element);
const body = q("main table tbody"); const onReady = (f) => {
body.innerHTML = "Loading..."; if (document.readyState === "complete") {
f();
const redirects = await fetch("/redirects.json", { } else {
headers: { authorization: `Bearer ${redirKey}` }, document.addEventListener("DOMContentLoaded", f);
}).then((r) => r.json());
body.innerHTML = "";
for (const redirect of redirects) {
body.appendChild(
h("tr", {}, [
h(
"td",
{},
h("a", { href: "/" + redirect.path }, [h("pre", {}, [redirect.path])])
),
h(
"td",
{ className: "clipped" },
h("a", { href: redirect.target_location }, [redirect.target_location])
),
h("td", {}, [redirect.redirect_code]),
])
);
} }
}; };
const setupForm = (redirKey) => { const populateRedirects = async (redirKey) => {
const form = h("form", {}, [ const status = q("main #status-message");
h("h2", {}, "create or replace"),
h("label", { for: "path" }, ["local path (random if empty)"]), let redirects;
h( try {
"input", redirects = await fetch("/redirects.json", {
{ headers: { authorization: `Bearer ${redirKey}` },
type: "text", })
name: "path", .then((r) => r.json())
id: "path", .catch();
placeholder: "my-cool-link", } catch (e) {
}, status.textContent =
[] "You are not authorized to fetch the list of redirects.";
), return;
}
h("label", { for: "url" }, ["target url"]), status.style.display = "none";
h(
"input",
{
type: "url",
name: "url",
id: "url",
placeholder: "https://…",
required: true,
},
[]
),
h("button", {}, ["Create"]), const table = t("template#redirect-table", "table");
]); const body = q("tbody", table);
q("main").appendChild(table);
for (const redirect of redirects) {
const deleteBody = new URLSearchParams();
deleteBody.append("path", redirect.path);
const row = t("template#redirect-row", "tr");
const rowEntries = row.querySelectorAll("td");
rowEntries[0].querySelector("a").href = `/${redirect.path}`;
rowEntries[0].querySelector("pre").textContent = redirect.path;
rowEntries[1].querySelector("a").href = redirect.target_location;
rowEntries[1].querySelector("a").textContent = redirect.target_location;
rowEntries[2].textContent = redirect.redirect_code;
rowEntries[3]
.querySelector("button")
.addEventListener("click", async (e) => {
await fetch("/shorten", {
method: "DELETE",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Bearer ${redirKey}`,
},
body: deleteBody,
});
row.remove();
});
body.appendChild(row);
}
};
const setupRedirectForm = (redirKey) => {
const form = t("template#redirect-form", "form");
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
@ -86,9 +91,13 @@ const setupForm = (redirKey) => {
q("main").appendChild(form); q("main").appendChild(form);
}; };
r(async () => { onReady(async () => {
const redirKey = window.localStorage.getItem("redirect-key"); const redirKey = window.localStorage.getItem("char.lt/redirect-key");
if (redirKey == null) return; if (redirKey == null) {
// TODO: Show a form entry for setting the redirect key
return;
}
await Promise.all([populateRedirects(redirKey), setupForm(redirKey)]); await populateRedirects(redirKey);
setupRedirectForm(redirKey);
}); });

View File

@ -5,8 +5,8 @@
<title>shorten - char.lt</title> <title>shorten - char.lt</title>
<link rel="stylesheet" href="/css/layout.css" /> <link rel="stylesheet" href="/css/shortlinks-layout.css" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/aesthetic.css" />
<link rel="stylesheet" href="/inter/inter.css" /> <link rel="stylesheet" href="/inter/inter.css" />
</head> </head>
@ -14,29 +14,60 @@
<body> <body>
<div class="page-wrapper"> <div class="page-wrapper">
<main> <main>
<h1>redirects</h1> <h1>Redirects</h1>
<p>this is where you can manage the redirects for the site.</p> <p>this is where you can manage the redirects for the site.</p>
<noscript>if you have javascript turned on.</noscript>
<table> <section id="status-message">Loading...</section>
<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> </main>
</div> </div>
<template id="redirect-table">
<table>
<thead>
<tr>
<th>Path</th>
<th>URL</th>
<th>Code</th>
<th>Manage</th>
</tr>
</thead>
<tbody></tbody>
</table>
</template>
<template id="redirect-form">
<form>
<h2>Create or Replace</h2>
<label for="path">Local Path (random if empty)</label>
<input type="text" name="path" id="path" placeholder="my-cool-link" />
<label for="url">Target URL</label>
<input
type="url"
name="url"
id="url"
placeholder="https://…"
required
/>
<button>Create</button>
</form>
</template>
<template id="redirect-row">
<tr>
<td>
<a href="{path}"><pre>{path}</pre></a>
</td>
<td class="clipped"><a href="{location}">{location}</a></td>
<td>{code}</td>
<td><button class="danger">Delete</button></td>
</tr>
</template>
<script type="module" src="/js/shorten.js"></script> <script type="module" src="/js/shorten.js"></script>
</body> </body>
</html> </html>