initial implementation
12
.editorConfig
Normal 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
|
|
@ -1 +1,2 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
data/*.sqlite
|
||||||
|
|
|
||||||
0
data/.gitkeep
Normal file
46
src/html/homepage.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||||
156
src/index.ts
|
|
@ -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) => {
|
import { Database } from "bun:sqlite";
|
||||||
return c.text('Hello Hono!')
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
BIN
static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 306 B |
BIN
static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 637 B |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
static/logo.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
1
static/logo.svg
Normal 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 |