276 lines
8 KiB
TypeScript
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);
|