initial implementation

This commit is contained in:
Nicola Zambello 2024-03-10 00:38:36 +02:00
parent 370f43f1ff
commit 5bd28aa150
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
16 changed files with 349 additions and 6 deletions

12
.editorConfig Normal file
View file

@ -0,0 +1,12 @@
[*]
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[{*.css,*.scss,*.less,*.overrides,*.variables}]
indent_size = 4
[{*.js,*.jsx,*.json,*.ts,*.tsx}]
indent_size = 2

1
.gitignore vendored
View file

@ -1 +1,2 @@
node_modules
data/*.sqlite

0
data/.gitkeep Normal file
View file

46
src/html/homepage.ts Normal file
View file

@ -0,0 +1,46 @@
import { html } from "hono/html";
import layout from "./layout";
const homepage = layout(html`
<main class="container">
<h1>Link Shortner</h1>
<style>
h1 {
margin-top: 2rem;
margin-bottom: 3rem;
text-align: center;
}
</style>
<article
style="max-width: 600px; margin-left: auto; margin-right: auto; padding: 3rem 2rem;"
>
<form action="/api/shorten" method="post">
<fieldset>
<label>
URL
<input
type="url"
name="url"
placeholder="https://example.com"
required
/>
</label>
<label>
Expires
<select name="expiration">
<option value="never" selected>Never</option>
<option value="hour">An hour</option>
<option value="day">A day</option>
<option value="week">A week</option>
<option value="month">A month</option>
</select>
</label>
</fieldset>
<button type="submit">Shorten</button>
</form>
</article>
</main>
`);
export default homepage;

40
src/html/layout.ts Normal file
View file

@ -0,0 +1,40 @@
import { html } from "hono/html";
const layout = (children: any) => html`
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charSet="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="og:type" content="website" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<title>Link Shortner</title>
<meta name="description" content="Get a shorten version of your links, make them expire or last forever">
<head prefix="og: http://ogp.me/ns#">
<meta property="og:type" content="article">
<meta property="og:title" content="Link Shortner">
<meta property="og:description" content="Get a shorten version of your links, make them expire or last forever">
<meta property="og:image" content="/logo.png">
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Link Shortner">
<meta property="twitter:description" content="Get a shorten version of your links, make them expire or last forever">
<meta property="twitter:image" content="/logo.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
</head>
<body>
${children}
</body>
</html>
`;
export default layout;

75
src/html/linkDetails.ts Normal file
View file

@ -0,0 +1,75 @@
import { html } from "hono/html";
import layout from "./layout";
const linkDetails = (link: {
id: string;
url: string;
expires_at?: string;
created_at: string;
}) =>
layout(html`
<main class="container">
<a href="/">
<h1>Link Shortner</h1>
</a>
<style>
h1 {
margin-top: 2rem;
margin-bottom: 3rem;
text-align: center;
}
</style>
<article>
<h2>Link Details</h2>
<p style="margin: 1em 0 0">Target URL:</p>
<code>
<a href="${link.url}" target="_blank" rel="noopener noreferrer"
>${link.url}</a
>
</code>
<p style="margin: 1em 0 0">Expires:</p>
<p>
<time
datetime="${link.expires_at?.length
? new Date(link.expires_at).toISOString()
: ""}"
>
${link.expires_at?.length
? new Date(link.expires_at).toLocaleString()
: "Never"}
</time>
</p>
<p style="margin: 1em 0 0">Short URL:</p>
<code>
<a
id="short-url"
href="/${link.id}"
target="_blank"
rel="noopener noreferrer"
>
/${link.id}
</a>
</code>
<button style="padding: 0.5rem 1rem; font-size: 0.5rem;">Copy</button>
</article>
<script>
const shortUrl = document.getElementById("short-url");
const baseUrl = window.location.origin;
shortUrl.textContent = baseUrl + shortUrl.textContent.trim();
shortUrl.parentElement.nextElementSibling.addEventListener(
"click",
() => {
navigator.clipboard.writeText(shortUrl.textContent);
}
);
</script>
</main>
`);
export default linkDetails;

24
src/html/notFound.ts Normal file
View file

@ -0,0 +1,24 @@
import { html } from "hono/html";
import layout from "./layout";
const notFound = layout(html`
<main class="container">
<a href="/">
<h1>Link Shortner</h1>
</a>
<style>
h1 {
margin-top: 2rem;
margin-bottom: 3rem;
text-align: center;
}
</style>
<article style="text-align: center">
<h2>Not Found</h2>
<p>The link you are trying to access does not exist or has expired.</p>
</article>
</main>
`);
export default notFound;

View file

@ -1,9 +1,153 @@
import { Hono } from 'hono'
import { Hono } from "hono";
import { cors } from "hono/cors";
import { etag } from "hono/etag";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { serveStatic } from "hono/bun";
const app = new Hono()
import notFound from "./html/notFound";
import homepage from "./html/homepage";
import linkDetails from "./html/linkDetails";
app.get('/', (c) => {
return c.text('Hello Hono!')
})
import { Database } from "bun:sqlite";
export default app
const db = new Database("./data/links.sqlite", { create: true });
const prepareDb = () => {
db.prepare(
`CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT current_timestamp
)`
).run();
};
prepareDb();
const deleteExpiredLinks = () => {
db.prepare(
"DELETE FROM links WHERE datetime(expires_at) < datetime('now')"
).run();
setTimeout(deleteExpiredLinks, 1000 * 60 * 60);
};
deleteExpiredLinks();
const app = new Hono();
app.use(prettyJSON());
app.notFound((c) => c.html(notFound, 404));
app.use(etag(), logger());
app.use("/favicon.ico", serveStatic({ path: "./static/favicon.ico" }));
app.use("/logo.svg", serveStatic({ path: "./static/logo.svg" }));
app.use("/logo.png", serveStatic({ path: "./static/logo.png" }));
app.use(
"/apple-touch-icon.png",
serveStatic({ path: "./static/apple-touch-icon.png" })
);
app.use(
"/favicon-32x32.png",
serveStatic({ path: "./static/favicon-32x32.png" })
);
app.use(
"/favicon-16x16.png",
serveStatic({ path: "./static/favicon-16x16.png" })
);
app.use("/api/*", cors());
app.get("/", (c) => {
return c.html(homepage);
});
app.get("/:id", (c) => {
const { id } = c.req.param();
const link = db.prepare("SELECT * FROM links WHERE id = ?").get(id) as
| {
id: string;
url: string;
expires_at: string;
created_at: string;
}
| undefined;
if (link) {
if (link.expires_at?.length) {
const expiresAt = new Date(link.expires_at);
if (expiresAt < new Date()) {
return c.html(notFound, 404);
}
}
return c.redirect(link.url);
}
return c.html(notFound, 404);
});
app.get("/link/:id", (c) => {
const { id } = c.req.param();
const link = db.prepare("SELECT * FROM links WHERE id = ?").get(id) as
| {
id: string;
url: string;
expires_at?: string;
created_at: string;
}
| undefined;
if (link) {
return c.html(linkDetails(link));
}
return c.html(notFound, 404);
});
app.post("/api/shorten", async (c) => {
const formData = await c.req.formData();
const url = formData.get("url") as string;
const expiration = formData.get("expiration") as string | null | undefined;
let expires_at = "";
if (expiration?.length && expiration !== "never") {
const date = new Date();
switch (expiration) {
case "hour":
date.setHours(date.getHours() + 1);
break;
case "day":
date.setDate(date.getDate() + 1);
break;
case "week":
date.setDate(date.getDate() + 7);
break;
case "month":
date.setMonth(date.getMonth() + 1);
break;
default:
break;
}
expires_at = date.toISOString();
}
const id = Math.random().toString(36).slice(2, 9);
const query = expires_at?.length
? `INSERT INTO links (id, url, expires_at) VALUES ('${id}', '${url}', '${expires_at}')`
: `INSERT INTO links (id, url) VALUES ('${id}', '${url}')`;
console.log(query);
db.prepare(query).run();
return c.redirect(`/link/${id}`);
});
export default {
port: Bun.env.PORT || 8787,
fetch: app.fetch,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

1
static/logo.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M1.38757706,5.69087183 C0.839076291,5.14050909 0.5,4.38129902 0.5,3.54289344 C0.5,1.8623496 1.8623496,0.5 3.542893,0.5 L8.457107,0.5 C10.1376504,0.5 11.5,1.86235004 11.5,3.54289344 C11.5,5.22343727 10.1376504,6.5 8.457107,6.5 L6,6.5" transform="translate(3 6)"/><path d="M4.38757706,8.69087183 C3.83907629,8.14050909 3.5,7.38129902 3.5,6.54289344 C3.5,4.8623496 4.8623496,3.5 6.542893,3.5 L11.457107,3.5 C13.1376504,3.5 14.5,4.86235004 14.5,6.54289344 C14.5,8.22343727 13.1376504,9.5 11.457107,9.5 L9,9.5" transform="translate(3 6) rotate(-180 9 6.5)"/></g></svg>

After

Width:  |  Height:  |  Size: 781 B