8e2f75aa77
Co-authored-by: Codex <codex@openai.com>
297 lines
7.8 KiB
TypeScript
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`);
|
|
}
|