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
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
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 |