diff --git a/.editorConfig b/.editorConfig new file mode 100644 index 0000000..afbd9e3 --- /dev/null +++ b/.editorConfig @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3c3629e..6eb5a28 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +data/*.sqlite diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/html/homepage.ts b/src/html/homepage.ts new file mode 100644 index 0000000..36190f4 --- /dev/null +++ b/src/html/homepage.ts @@ -0,0 +1,46 @@ +import { html } from "hono/html"; +import layout from "./layout"; + +const homepage = layout(html` +
+

Link Shortner

+ + +
+
+
+ + +
+ +
+
+
+`); + +export default homepage; diff --git a/src/html/layout.ts b/src/html/layout.ts new file mode 100644 index 0000000..3d7bae9 --- /dev/null +++ b/src/html/layout.ts @@ -0,0 +1,40 @@ +import { html } from "hono/html"; + +const layout = (children: any) => html` + + + + + + + + + + + + + + + Link Shortner + + + + + + + + + + + + + + + + + ${children} + + +`; + +export default layout; diff --git a/src/html/linkDetails.ts b/src/html/linkDetails.ts new file mode 100644 index 0000000..8405f76 --- /dev/null +++ b/src/html/linkDetails.ts @@ -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` +
+ +

Link Shortner

+
+ + +
+

Link Details

+ +

Target URL:

+ + ${link.url} + + +

Expires:

+

+ +

+ +

Short URL:

+ + + /${link.id} + + + +
+ +
+ `); + +export default linkDetails; diff --git a/src/html/notFound.ts b/src/html/notFound.ts new file mode 100644 index 0000000..27771d8 --- /dev/null +++ b/src/html/notFound.ts @@ -0,0 +1,24 @@ +import { html } from "hono/html"; +import layout from "./layout"; + +const notFound = layout(html` +
+ +

Link Shortner

+
+ + +
+

Not Found

+

The link you are trying to access does not exist or has expired.

+
+
+`); + +export default notFound; diff --git a/src/index.ts b/src/index.ts index 3191383..d37272e 100644 --- a/src/index.ts +++ b/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, +}; diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000..ddf5134 Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..50151e0 Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..2177b8c Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..7335583 Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..c050cde Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..d962d93 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..92b2705 Binary files /dev/null and b/static/logo.png differ diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..ae6e160 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file