From 9de4992da51df415825d25aa026c3fcfce25df75 Mon Sep 17 00:00:00 2001 From: dexx Date: Fri, 5 Jun 2026 21:48:49 +0300 Subject: [PATCH] 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 --- apps/api/src/admin.ts | 21 +++++++++++++++--- apps/api/src/index.ts | 50 +++++++++++++++++++++++++++---------------- apps/api/src/store.ts | 25 +++++++++++++++++----- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/api/src/admin.ts b/apps/api/src/admin.ts index 73de3c5..450496c 100644 --- a/apps/api/src/admin.ts +++ b/apps/api/src/admin.ts @@ -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)); return pageShell( @@ -52,6 +52,12 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string } + ${ + options.message + ? `
${escapeHtml(options.message)}
` + : "" + } +

New card

@@ -201,7 +207,8 @@ textarea { .mast, .editor, .cardEditor, -.empty { +.empty, +.noticeBanner { border: 2px solid var(--color-line); background: var(--color-panel); box-shadow: var(--shadow-hard); @@ -283,10 +290,18 @@ h1 { .editor, .cardEditor, -.empty { +.empty, +.noticeBanner { padding: 18px; } +.noticeBanner { + margin-top: 28px; + color: var(--color-ink); + font-family: var(--font-mono); + overflow-wrap: anywhere; +} + .editor { position: sticky; top: 18px; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 12308f9..c051e44 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -104,6 +104,10 @@ async function cardFromForm(form: FormData, existing?: BoardCard): Promise card.id === id); - const nextCard = await cardFromForm(form, existing); - const nextCards = existing - ? cards.map((card) => (card.id === existing.id ? nextCard : card)) - : [nextCard, ...cards]; + try { + 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"); + await writeCards(nextCards); + return redirect("/admin"); + } catch (error) { + return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card."); + } }, { detail: { tags: ["admin"], @@ -269,17 +277,21 @@ const app = new Elysia() 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); + try { + 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); + if (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: { tags: ["admin"], diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index d69f9b1..8ac31e4 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -17,10 +17,13 @@ const localDataDir = path.resolve(".data"); const localUploadsDir = path.join(localDataDir, "uploads"); 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 { - if (process.env.BLOB_READ_WRITE_TOKEN) { + if (hasBlobToken()) { try { const result = await get(dataBlobPath, { access: "private" }); if (!result?.stream) { @@ -47,7 +50,7 @@ export async function readCards(): Promise { export async function writeCards(cards: BoardCard[]) { const content = JSON.stringify(cards, null, 2); - if (process.env.BLOB_READ_WRITE_TOKEN) { + if (hasBlobToken()) { await put(dataBlobPath, content, { access: "private", contentType: "application/json", @@ -56,6 +59,7 @@ export async function writeCards(cards: BoardCard[]) { return; } + assertWritableLocalStorage(); await mkdir(localDataDir, { recursive: true }); await writeFile(localCardsPath, content, "utf8"); } @@ -64,7 +68,7 @@ export async function saveImage(file: File) { const safeName = safeFileName(file.name || "image"); const pathname = `pozor/images/${Date.now()}-${safeName}`; - if (process.env.BLOB_READ_WRITE_TOKEN) { + if (hasBlobToken()) { const blob = await put(pathname, file, { access: "public", contentType: file.type || "application/octet-stream" @@ -72,6 +76,7 @@ export async function saveImage(file: File) { return blob.url; } + assertWritableLocalStorage(); await mkdir(localUploadsDir, { recursive: true }); const localName = `${Date.now()}-${safeName}`; const localPath = path.join(localUploadsDir, localName); @@ -80,7 +85,7 @@ export async function saveImage(file: File) { } 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; } @@ -95,6 +100,16 @@ export function localUploadPath(filename: string) { 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[]) { return cards .filter((card) => card.published)