first commit
This commit is contained in:
commit
672f0deb6a
18 changed files with 1274 additions and 0 deletions
71
backend/db.ts
Normal file
71
backend/db.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
|
||||
export const db = new Database("links.sqlite", { create: true });
|
||||
|
||||
db.run("PRAGMA journal_mode = WAL;");
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id TEXT PRIMARY KEY,
|
||||
target TEXT NOT NULL,
|
||||
short TEXT NOT NULL UNIQUE,
|
||||
ogTitle TEXT,
|
||||
ogDesc TEXT,
|
||||
ogImage TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
linkId TEXT PRIMARY KEY,
|
||||
clicks INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Use positional placeholders ?
|
||||
export const insertLinkStmt = db.query(`
|
||||
INSERT INTO links (id, target, short, ogTitle, ogDesc, ogImage)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
export const insertStatsStmt = db.query(`
|
||||
INSERT INTO stats (linkId, clicks) VALUES (?, 0)
|
||||
`);
|
||||
|
||||
export const getAllLinksStmt = db.query(`
|
||||
SELECT l.*, s.clicks
|
||||
FROM links l
|
||||
LEFT JOIN stats s ON l.id = s.linkId
|
||||
`);
|
||||
|
||||
export const getLinkByIdStmt = db.query(`
|
||||
SELECT l.*, s.clicks
|
||||
FROM links l
|
||||
LEFT JOIN stats s ON l.id = s.linkId
|
||||
WHERE l.id = ?
|
||||
`);
|
||||
|
||||
export const getLinkByShortStmt = db.query(`
|
||||
SELECT * FROM links WHERE short = ?
|
||||
`);
|
||||
|
||||
export const updateLinkStmt = db.query(`
|
||||
UPDATE links SET
|
||||
target = COALESCE(?, target),
|
||||
short = COALESCE(?, short),
|
||||
ogTitle = COALESCE(?, ogTitle),
|
||||
ogDesc = COALESCE(?, ogDesc),
|
||||
ogImage = COALESCE(?, ogImage)
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
export const deleteLinkStmt = db.query(`DELETE FROM links WHERE id = ?`);
|
||||
export const deleteStatsStmt = db.query(`DELETE FROM stats WHERE linkId = ?`);
|
||||
|
||||
export const incClickStmt = db.query(`
|
||||
UPDATE stats SET clicks = clicks + 1 WHERE linkId = ?
|
||||
`);
|
||||
|
||||
export const getStatsByIdStmt = db.query(`
|
||||
SELECT clicks FROM stats WHERE linkId = ?
|
||||
`);
|
||||
276
backend/index.ts
Normal file
276
backend/index.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import * as fs from "fs";
|
||||
import { z } from "zod";
|
||||
import { deleteLinkStmt,
|
||||
getAllLinksStmt,
|
||||
getLinkByIdStmt,
|
||||
getLinkByShortStmt,
|
||||
getStatsByIdStmt,
|
||||
incClickStmt, insertLinkStmt, insertStatsStmt, updateLinkStmt } from "./db";
|
||||
|
||||
import type { LinkEntry } from "../common/lib";
|
||||
|
||||
const linkSchema = z.object({
|
||||
target: z.string().url({ message: "target must be a valid URL" }),
|
||||
short: z.string().min(1, { message: "short cannot be empty" }),
|
||||
ogTitle: z.string().max(60).optional(),
|
||||
ogDesc: z.string().max(120).optional(),
|
||||
ogImage: z.union([
|
||||
z.literal(""),
|
||||
z.string().trim().url({ message: "ogImage must be a valid URL" })
|
||||
])
|
||||
});
|
||||
|
||||
|
||||
function requireAuth(req: Request) {
|
||||
const pwd = req.headers.get("X-Password");
|
||||
if (pwd !== process.env.PASSWORD) {
|
||||
return new Response(JSON.stringify({ success: false, error: "Unauthorized" }), { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export class ClientResponse extends Response {
|
||||
constructor(body?: BodyInit, init?: ResponseInit) {
|
||||
super(body, init);
|
||||
this.headers.set("Access-Control-Allow-Origin", process.env.ORIGIN!);
|
||||
this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, DELETE");
|
||||
this.headers.set("Access-Control-Allow-Headers", "Content-Type, X-Password");
|
||||
}
|
||||
|
||||
static override json(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": process.env.ORIGIN!,
|
||||
"Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, DELETE",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Password",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------- Server ---------------------
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: process.env.HOST || "127.0.0.1",
|
||||
routes: {
|
||||
"/api/status": () => new ClientResponse("OK"),
|
||||
|
||||
"/api/login": {
|
||||
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||
POST: (req) => {
|
||||
const pwd = req.headers.get("X-Password");
|
||||
if (pwd !== process.env.PASSWORD) {
|
||||
return ClientResponse.json({ success: false, error: "Invalid password" }, { status: 403 });
|
||||
}
|
||||
return ClientResponse.json({ success: true });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/links": {
|
||||
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||
GET: (req) => {
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const rows = getAllLinksStmt.all();
|
||||
return ClientResponse.json({ success: true, data: rows });
|
||||
},
|
||||
|
||||
POST: async (req) => {
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = linkSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return ClientResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: Object.values(z.treeifyError(parsed.error).properties!)
|
||||
.flatMap(z => z.errors)
|
||||
.join(", "),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).slice(2, 11);
|
||||
const safe = {
|
||||
id,
|
||||
target: parsed.data.target,
|
||||
short: parsed.data.short,
|
||||
ogTitle: parsed.data.ogTitle ?? "",
|
||||
ogDesc: parsed.data.ogDesc ?? "",
|
||||
ogImage: parsed.data.ogImage ?? "",
|
||||
};
|
||||
insertLinkStmt.run(
|
||||
safe.id,
|
||||
safe.target,
|
||||
safe.short,
|
||||
safe.ogTitle,
|
||||
safe.ogDesc,
|
||||
safe.ogImage
|
||||
);
|
||||
insertStatsStmt.run(id);
|
||||
|
||||
const newLink = getLinkByIdStmt.get(id);
|
||||
return ClientResponse.json({ success: true, data: newLink });
|
||||
},
|
||||
},
|
||||
"/api/link/:short": (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new ClientResponse("Departed", { status: 204 });
|
||||
}
|
||||
|
||||
const short = req.params.short;
|
||||
const link = getLinkByShortStmt.get(short) as LinkEntry;
|
||||
|
||||
if (!link) {
|
||||
return new ClientResponse("Missing", { status: 404 });
|
||||
}
|
||||
|
||||
incClickStmt.run(link.id);
|
||||
|
||||
const ua = req.headers.get("user-agent")?.toLowerCase() || "";
|
||||
|
||||
const isBot =
|
||||
ua.includes("facebookexternalhit") ||
|
||||
ua.includes("twitterbot") ||
|
||||
ua.includes("discordbot") ||
|
||||
ua.includes("linkedinbot") ||
|
||||
ua.includes("slackbot") ||
|
||||
ua.includes("telegrambot");;
|
||||
|
||||
if (isBot && (link.ogTitle || link.ogDesc || link.ogImage)) {
|
||||
const title = link.ogTitle || link.short;
|
||||
const description = link.ogDesc || "";
|
||||
const image = link.ogImage || "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${image}">
|
||||
<meta property="og:url" content="${link.target}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting to <a href="${link.target}">${link.target}</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// normal redirect
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: link.target },
|
||||
});
|
||||
},
|
||||
|
||||
// CRUD for 1 link
|
||||
"/api/links/:id": {
|
||||
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||
|
||||
GET: (req) => {
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const id = req.params.id;
|
||||
const row = getLinkByIdStmt.get(id);
|
||||
if (!row) {
|
||||
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return ClientResponse.json({ success: true, data: row });
|
||||
},
|
||||
|
||||
PUT: async (req) => {
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const id = req.params.id;
|
||||
const row = getLinkByIdStmt.get(id);
|
||||
if (!row) {
|
||||
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = linkSchema.partial().safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return ClientResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: Object.values(z.treeifyError(parsed.error).properties!)
|
||||
.flatMap(z => z.errors)
|
||||
.join(", "),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateLinkStmt.run(
|
||||
parsed.data.target ?? null,
|
||||
parsed.data.short ?? null,
|
||||
parsed.data.ogTitle ?? null,
|
||||
parsed.data.ogDesc ?? null,
|
||||
parsed.data.ogImage ?? null,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = getLinkByIdStmt.get(id);
|
||||
|
||||
return ClientResponse.json({ success: true, data: updated });
|
||||
},
|
||||
|
||||
DELETE: (req) => {
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const id = req.params.id;
|
||||
deleteLinkStmt.run(id);
|
||||
deleteLinkStmt.run(id);
|
||||
|
||||
return ClientResponse.json({ success: true });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/links/:id/stats": (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new ClientResponse("Departed", { status: 204 });
|
||||
}
|
||||
|
||||
const authErr = requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
const id = req.params.id;
|
||||
const row = getStatsByIdStmt.get(id);
|
||||
|
||||
if (!row) {
|
||||
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return ClientResponse.json({ success: true, data: row });
|
||||
},
|
||||
|
||||
"/api/*": () =>
|
||||
ClientResponse.json({ success: false, error: "Not found" }, { status: 404 }),
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Server running on http://localhost:" + server.port);
|
||||
Loading…
Add table
Add a link
Reference in a new issue