link_shortener/backend/index.ts
2025-12-12 21:12:47 +02:00

276 lines
8 KiB
TypeScript

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);