Files
pozor/apps/api/src/index.ts
T
2026-06-05 21:12:48 +03:00

297 lines
7.8 KiB
TypeScript

import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia";
import { readFile } from "node:fs/promises";
import { renderAdmin, renderLogin } from "./admin";
import {
type BoardCard,
deleteRemoteImage,
localUploadPath,
publicCards,
readCards,
saveImage,
storageMode,
writeCards
} from "./store";
const adminCookie = "pozor_admin";
const port = Number(process.env.PORT || 3001);
const html = (body: string, init?: ResponseInit) =>
new Response(body, {
...init,
headers: {
"Content-Type": "text/html; charset=utf-8",
...(init?.headers || {})
}
});
const redirect = (to: string, init?: ResponseInit) =>
new Response(null, {
status: 303,
...init,
headers: {
Location: to,
...(init?.headers || {})
}
});
const json = (body: unknown, init?: ResponseInit) =>
Response.json(body, {
...init,
headers: {
"Access-Control-Allow-Origin": "*",
...(init?.headers || {})
}
});
function parseCookies(request: Request) {
return Object.fromEntries(
(request.headers.get("cookie") || "")
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
})
);
}
async function authToken() {
const password = getAdminPassword();
if (!password) {
return "";
}
const data = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function isAuthed(request: Request) {
const token = await authToken();
return Boolean(token) && parseCookies(request)[adminCookie] === token;
}
function getAdminPassword() {
return process.env.ADMIN_PASSWORD?.trim() || "";
}
async function cardFromForm(form: FormData, existing?: BoardCard): Promise<BoardCard> {
const now = new Date().toISOString();
const title = String(form.get("title") || "").trim();
const comment = String(form.get("comment") || "").trim();
let imageUrl = String(form.get("imageUrl") || "").trim();
const file = form.get("image");
if (file instanceof File && file.size > 0) {
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
imageUrl = await saveImage(file);
}
return {
id: existing?.id || crypto.randomUUID(),
title,
comment,
imageUrl,
published: form.get("published") === "on",
createdAt: existing?.createdAt || now,
updatedAt: now
};
}
const app = new Elysia()
.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Pozor Board API",
version: "1.0.0",
description:
"API and admin endpoints for the dark-only shame board. Public routes expose published cards; admin routes require the admin session cookie."
},
servers: [
{
url: process.env.PUBLIC_API_URL || `http://localhost:${port}`,
description: "API server"
}
],
tags: [
{
name: "public",
description: "Published board data"
},
{
name: "admin",
description: "Admin panel and card management"
},
{
name: "uploads",
description: "Local development uploads"
}
]
}
})
)
.get("/openapi.json", async ({ request }) => {
const url = new URL("/docs/json", request.url);
return fetch(url);
}, {
detail: {
tags: ["public"],
summary: "OpenAPI JSON alias",
description: "Redirects to the generated OpenAPI document."
}
})
.get("/", () => redirect("/admin"), {
detail: {
tags: ["admin"],
summary: "Redirect to admin panel"
}
})
.get("/api/cards", async () => {
const cards = publicCards(await readCards());
return json({ docs: cards });
}, {
detail: {
tags: ["public"],
summary: "List published cards",
description: "Returns cards visible on the public Astro board."
}
})
.get("/api/admin/cards", async ({ request }) => {
if (!(await isAuthed(request))) {
return json({ error: "Unauthorized" }, { status: 401 });
}
return json({ docs: await readCards() });
}, {
detail: {
tags: ["admin"],
summary: "List all cards",
description: "Requires a valid admin session cookie."
}
})
.get("/uploads/:filename", async ({ params }) => {
try {
const file = await readFile(localUploadPath(params.filename));
return new Response(file);
} catch {
return new Response("Not found", { status: 404 });
}
}, {
detail: {
tags: ["uploads"],
summary: "Read local uploaded file",
description: "Only used in local file storage mode. Blob uploads return public Blob URLs."
}
})
.get("/admin", async ({ request }) => {
if (!(await isAuthed(request))) {
return html(renderLogin(getAdminPassword() ? "" : "Set ADMIN_PASSWORD in .env to enable login."));
}
return html(renderAdmin(await readCards(), { storageMode }));
}, {
detail: {
tags: ["admin"],
summary: "Admin panel"
}
})
.post("/admin/login", async ({ request }) => {
const form = await request.formData();
const password = String(form.get("password") || "");
const adminPassword = getAdminPassword();
if (!adminPassword) {
return html(renderLogin("ADMIN_PASSWORD is not set."), { status: 403 });
}
if (adminPassword && password !== adminPassword) {
return html(renderLogin("Wrong password."), { status: 401 });
}
return redirect("/admin", {
headers: {
"Set-Cookie": `${adminCookie}=${await authToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`
}
});
}, {
detail: {
tags: ["admin"],
summary: "Login to admin panel",
description: "Accepts form data with a password field."
}
})
.post("/admin/logout", () =>
redirect("/admin", {
headers: {
"Set-Cookie": `${adminCookie}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
}
}), {
detail: {
tags: ["admin"],
summary: "Logout from admin panel"
}
}
)
.post("/admin/cards", async ({ request }) => {
if (!(await isAuthed(request))) {
return redirect("/admin");
}
const form = await request.formData();
const cards = await readCards();
const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id);
const nextCard = await cardFromForm(form, existing);
const nextCards = existing
? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await writeCards(nextCards);
return redirect("/admin");
}, {
detail: {
tags: ["admin"],
summary: "Create or update a card",
description: "Requires a valid admin session cookie. Accepts multipart form data."
}
})
.post("/admin/cards/delete", async ({ request }) => {
if (!(await isAuthed(request))) {
return redirect("/admin");
}
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
const existing = cards.find((card) => card.id === id);
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
await writeCards(cards.filter((card) => card.id !== id));
return redirect("/admin");
}, {
detail: {
tags: ["admin"],
summary: "Delete a card",
description: "Requires a valid admin session cookie. Accepts form data with an id field."
}
});
export default app;
if (process.env.VERCEL !== "1") {
app.listen(port);
console.log(`Pozor admin: http://localhost:${port}/admin`);
}