1 Commits

Author SHA1 Message Date
dexx 9de4992da5 Add error handling and storage safety checks to admin
Show inline error banners when card save/delete fails instead of
crashing. Prevent writes to local filesystem on Vercel where it would
silently fail by validating BLOB_READ_WRITE_TOKEN presence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 21:48:49 +03:00
3 changed files with 69 additions and 27 deletions
+18 -3
View File
@@ -29,7 +29,7 @@ export function renderLogin(message = "") {
); );
} }
export function renderAdmin(cards: BoardCard[], options: { storageMode: string }) { export function renderAdmin(cards: BoardCard[], options: { storageMode: string; message?: string }) {
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
return pageShell( return pageShell(
@@ -52,6 +52,12 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
</aside> </aside>
</header> </header>
${
options.message
? `<section class="noticeBanner">${escapeHtml(options.message)}</section>`
: ""
}
<section class="workbench"> <section class="workbench">
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data"> <form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
<p class="stamp">New card</p> <p class="stamp">New card</p>
@@ -201,7 +207,8 @@ textarea {
.mast, .mast,
.editor, .editor,
.cardEditor, .cardEditor,
.empty { .empty,
.noticeBanner {
border: 2px solid var(--color-line); border: 2px solid var(--color-line);
background: var(--color-panel); background: var(--color-panel);
box-shadow: var(--shadow-hard); box-shadow: var(--shadow-hard);
@@ -283,10 +290,18 @@ h1 {
.editor, .editor,
.cardEditor, .cardEditor,
.empty { .empty,
.noticeBanner {
padding: 18px; padding: 18px;
} }
.noticeBanner {
margin-top: 28px;
color: var(--color-ink);
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.editor { .editor {
position: sticky; position: sticky;
top: 18px; top: 18px;
+31 -19
View File
@@ -104,6 +104,10 @@ async function cardFromForm(form: FormData, existing?: BoardCard): Promise<Board
}; };
} }
async function renderAdminWithMessage(message: string, status = 500) {
return html(renderAdmin(await readCards(), { storageMode, message }), { status });
}
const app = new Elysia() const app = new Elysia()
.use( .use(
swagger({ swagger({
@@ -246,17 +250,21 @@ const app = new Elysia()
return redirect("/admin"); return redirect("/admin");
} }
const form = await request.formData(); try {
const cards = await readCards(); const form = await request.formData();
const id = String(form.get("id") || ""); const cards = await readCards();
const existing = cards.find((card) => card.id === id); const id = String(form.get("id") || "");
const nextCard = await cardFromForm(form, existing); const existing = cards.find((card) => card.id === id);
const nextCards = existing const nextCard = await cardFromForm(form, existing);
? cards.map((card) => (card.id === existing.id ? nextCard : card)) const nextCards = existing
: [nextCard, ...cards]; ? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await writeCards(nextCards); await writeCards(nextCards);
return redirect("/admin"); return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
}
}, { }, {
detail: { detail: {
tags: ["admin"], tags: ["admin"],
@@ -269,17 +277,21 @@ const app = new Elysia()
return redirect("/admin"); return redirect("/admin");
} }
const form = await request.formData(); try {
const id = String(form.get("id") || ""); const form = await request.formData();
const cards = await readCards(); const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id); const cards = await readCards();
const existing = cards.find((card) => card.id === id);
if (existing?.imageUrl) { if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl); await deleteRemoteImage(existing.imageUrl);
}
await writeCards(cards.filter((card) => card.id !== id));
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
} }
await writeCards(cards.filter((card) => card.id !== id));
return redirect("/admin");
}, { }, {
detail: { detail: {
tags: ["admin"], tags: ["admin"],
+20 -5
View File
@@ -17,10 +17,13 @@ const localDataDir = path.resolve(".data");
const localUploadsDir = path.join(localDataDir, "uploads"); const localUploadsDir = path.join(localDataDir, "uploads");
const localCardsPath = path.join(localDataDir, "cards.json"); const localCardsPath = path.join(localDataDir, "cards.json");
export const storageMode = process.env.BLOB_READ_WRITE_TOKEN ? "Vercel Blob" : "Local file"; const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
const isVercel = () => process.env.VERCEL === "1";
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> { export async function readCards(): Promise<BoardCard[]> {
if (process.env.BLOB_READ_WRITE_TOKEN) { if (hasBlobToken()) {
try { try {
const result = await get(dataBlobPath, { access: "private" }); const result = await get(dataBlobPath, { access: "private" });
if (!result?.stream) { if (!result?.stream) {
@@ -47,7 +50,7 @@ export async function readCards(): Promise<BoardCard[]> {
export async function writeCards(cards: BoardCard[]) { export async function writeCards(cards: BoardCard[]) {
const content = JSON.stringify(cards, null, 2); const content = JSON.stringify(cards, null, 2);
if (process.env.BLOB_READ_WRITE_TOKEN) { if (hasBlobToken()) {
await put(dataBlobPath, content, { await put(dataBlobPath, content, {
access: "private", access: "private",
contentType: "application/json", contentType: "application/json",
@@ -56,6 +59,7 @@ export async function writeCards(cards: BoardCard[]) {
return; return;
} }
assertWritableLocalStorage();
await mkdir(localDataDir, { recursive: true }); await mkdir(localDataDir, { recursive: true });
await writeFile(localCardsPath, content, "utf8"); await writeFile(localCardsPath, content, "utf8");
} }
@@ -64,7 +68,7 @@ export async function saveImage(file: File) {
const safeName = safeFileName(file.name || "image"); const safeName = safeFileName(file.name || "image");
const pathname = `pozor/images/${Date.now()}-${safeName}`; const pathname = `pozor/images/${Date.now()}-${safeName}`;
if (process.env.BLOB_READ_WRITE_TOKEN) { if (hasBlobToken()) {
const blob = await put(pathname, file, { const blob = await put(pathname, file, {
access: "public", access: "public",
contentType: file.type || "application/octet-stream" contentType: file.type || "application/octet-stream"
@@ -72,6 +76,7 @@ export async function saveImage(file: File) {
return blob.url; return blob.url;
} }
assertWritableLocalStorage();
await mkdir(localUploadsDir, { recursive: true }); await mkdir(localUploadsDir, { recursive: true });
const localName = `${Date.now()}-${safeName}`; const localName = `${Date.now()}-${safeName}`;
const localPath = path.join(localUploadsDir, localName); const localPath = path.join(localUploadsDir, localName);
@@ -80,7 +85,7 @@ export async function saveImage(file: File) {
} }
export async function deleteRemoteImage(imageUrl: string) { export async function deleteRemoteImage(imageUrl: string) {
if (!process.env.BLOB_READ_WRITE_TOKEN || !imageUrl.includes(".blob.vercel-storage.com/")) { if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
return; return;
} }
@@ -95,6 +100,16 @@ export function localUploadPath(filename: string) {
return path.join(localUploadsDir, safeFileName(filename)); return path.join(localUploadsDir, safeFileName(filename));
} }
function assertWritableLocalStorage() {
if (!isVercel()) {
return;
}
throw new Error(
"BLOB_READ_WRITE_TOKEN is not set for the API deployment. Vercel functions cannot write to .data; connect Vercel Blob to the API project and redeploy."
);
}
export function publicCards(cards: BoardCard[]) { export function publicCards(cards: BoardCard[]) {
return cards return cards
.filter((card) => card.published) .filter((card) => card.published)