Replace Payload with Elysia admin
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user