Compare commits

...

8 Commits

Author SHA1 Message Date
dexx 5033ce8b4d Prevent data loss from read errors and concurrent writes
readCards now throws on fetch/parse failures instead of returning []
so a broken read can never trigger an overwrite of valid data.
Added a write lock to serialize card mutations within a single instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:53:17 +03:00
dexx 79460fb47d Prevent double-submit on admin forms
Disables submit buttons after first click to prevent race conditions
that could corrupt card data via concurrent writes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:27:52 +03:00
dexx d510bd944b Fix readCards for public blob store
The get() function doesn't work with relative paths on public stores.
Use list() to find the blob URL, then fetch it directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:26:14 +03:00
dexx 97e864bd06 Add lightbox for full-size image viewing
Clicking a card image now opens it full-screen in an overlay.
Click anywhere or press Escape to close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:16:54 +03:00
dexx 27b36c36c4 Switch board to client-side fetching
Cards now load dynamically on each page visit instead of at build time,
so new cards appear immediately without redeploying the site.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:08:15 +03:00
dexx ea273a4ac4 Fix blob access mode for public store
The new Vercel Blob store is public, so private access is not allowed.
Switch cards read/write to public access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:00:33 +03:00
dexx 34eb42bb09 Merge branch 'elysia-dev' 2026-06-05 21:48:55 +03:00
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
4 changed files with 219 additions and 139 deletions
+28 -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>
@@ -87,6 +93,16 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
</section> </section>
</section> </section>
</main> </main>
<script>
document.querySelectorAll("form").forEach(f => {
f.addEventListener("submit", () => {
f.querySelectorAll("button[type=submit]").forEach(b => {
b.disabled = true;
b.textContent = "Wait...";
});
});
});
</script>
` `
); );
} }
@@ -201,7 +217,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 +300,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;
+17
View File
@@ -10,6 +10,7 @@ import {
readCards, readCards,
saveImage, saveImage,
storageMode, storageMode,
withWriteLock,
writeCards writeCards
} from "./store"; } from "./store";
@@ -104,6 +105,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,6 +251,8 @@ const app = new Elysia()
return redirect("/admin"); return redirect("/admin");
} }
try {
await withWriteLock(async () => {
const form = await request.formData(); const form = await request.formData();
const cards = await readCards(); const cards = await readCards();
const id = String(form.get("id") || ""); const id = String(form.get("id") || "");
@@ -256,7 +263,11 @@ const app = new Elysia()
: [nextCard, ...cards]; : [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,6 +280,8 @@ const app = new Elysia()
return redirect("/admin"); return redirect("/admin");
} }
try {
await withWriteLock(async () => {
const form = await request.formData(); const form = await request.formData();
const id = String(form.get("id") || ""); const id = String(form.get("id") || "");
const cards = await readCards(); const cards = await readCards();
@@ -279,7 +292,11 @@ const app = new Elysia()
} }
await writeCards(cards.filter((card) => card.id !== id)); await writeCards(cards.filter((card) => card.id !== id));
});
return redirect("/admin"); return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
}
}, { }, {
detail: { detail: {
tags: ["admin"], tags: ["admin"],
+39 -34
View File
@@ -1,4 +1,4 @@
import { del, get, put } from "@vercel/blob"; import { del, list, put } from "@vercel/blob";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -17,45 +17,54 @@ 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";
let writeLock: Promise<void> = Promise.resolve();
export function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const next = writeLock.then(fn, fn);
writeLock = next.then(() => {}, () => {});
return next;
}
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 { const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
const result = await get(dataBlobPath, { access: "private" }); if (blobs.length === 0) return [];
if (!result?.stream) {
return [];
}
const text = await streamToText(result.stream); const res = await fetch(blobs[0].url);
const parsed = JSON.parse(text) as BoardCard[]; if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
} }
try { try {
const text = await readFile(localCardsPath, "utf8"); const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[]; const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch { } catch (err: any) {
return []; if (err?.code === "ENOENT") return [];
throw err;
} }
} }
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: "public",
contentType: "application/json", contentType: "application/json",
allowOverwrite: true allowOverwrite: true
}); });
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 +73,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 +81,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 +90,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 +105,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)
@@ -104,18 +124,3 @@ export function publicCards(cards: BoardCard[]) {
function safeFileName(name: string) { function safeFileName(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-"); return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
} }
async function streamToText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
+115 -82
View File
@@ -1,76 +1,12 @@
--- ---
import "../styles/global.css"; import "../styles/global.css";
type BoardCard = {
id: string | number;
title: string;
comment?: string | null;
imageUrl?: string | null;
published?: boolean;
};
type ApiResponse = {
docs?: BoardCard[];
};
type BoardItem = {
id: string | number;
title: string;
comment?: string | null;
image: {
alt: string;
url: string;
} | null;
};
const apiUrl = ( const apiUrl = (
import.meta.env.API_URL || import.meta.env.API_URL ||
import.meta.env.PUBLIC_API_URL || import.meta.env.PUBLIC_API_URL ||
"http://localhost:3001" "http://localhost:3001"
).replace(/\/$/, ""); ).replace(/\/$/, "");
async function getCards() {
try {
const response = await fetch(`${apiUrl}/api/cards`);
if (!response.ok) {
return [];
}
const data = (await response.json()) as ApiResponse;
return data.docs ?? [];
} catch {
return [];
}
}
function absolutize(url: string) {
if (/^https?:\/\//i.test(url)) {
return url;
}
return `${apiUrl}${url.startsWith("/") ? "" : "/"}${url}`;
}
function getImage(card: BoardCard) {
if (card.imageUrl) {
return {
alt: card.title,
url: absolutize(card.imageUrl)
};
}
return null;
}
const cards = await getCards();
const boardItems: BoardItem[] = cards.map((card) => ({
id: card.id,
title: card.title,
comment: card.comment,
image: getImage(card)
}));
const boardStamp = boardItems.length === 0 ? "Empty" : `${boardItems.length} filed`;
const adminUrl = `${apiUrl}/admin`; const adminUrl = `${apiUrl}/admin`;
--- ---
@@ -90,31 +26,128 @@ const adminUrl = `${apiUrl}/admin`;
<p class="summary">тут только самое позорное.</p> <p class="summary">тут только самое позорное.</p>
</div> </div>
<aside class="caseTag" aria-label="Board status"> <aside class="caseTag" aria-label="Board status">
<span>{boardStamp}</span> <span id="board-stamp">Loading...</span>
<strong>Elysia API</strong> <strong>Elysia API</strong>
<a class="loginButton" href={adminUrl}>Login</a> <a class="loginButton" href={adminUrl}>Login</a>
<small>admin / cards</small> <small>admin / cards</small>
</aside> </aside>
</section> </section>
<section class="board" aria-label="Published cards"> <section class="board" aria-label="Published cards" id="board">
{boardItems.length === 0 && <div class="notice">В админке пока нет опубликованных карточек.</div>} <div class="notice">Загрузка...</div>
{
boardItems.map((item, index) => {
return (
<article class="photoCard">
{item.image?.url && <img src={item.image.url} alt={item.image.alt} loading={index < 3 ? "eager" : "lazy"} />}
<div class="caption">
<span>#{String(index + 1).padStart(2, "0")}</span>
<strong>{item.title}</strong>
</div>
{item.comment && <p class="cardComment">{item.comment}</p>}
</article>
);
})
}
</section> </section>
</main> </main>
<div class="lightbox" id="lightbox" aria-hidden="true">
<button class="lightbox-close" aria-label="Close">&times;</button>
<img id="lightbox-img" src="" alt="" />
</div>
<style>
.lightbox {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.9);
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.lightbox.open {
display: flex;
}
.lightbox img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 24px;
font-size: 2.5rem;
color: #fff;
background: none;
border: none;
cursor: pointer;
line-height: 1;
}
.photoCard img {
cursor: zoom-in;
}
</style>
<script define:vars={{ apiUrl }}>
const API = apiUrl;
function absolutize(url) {
if (/^https?:\/\//i.test(url)) return url;
return `${API}${url.startsWith("/") ? "" : "/"}${url}`;
}
async function loadCards() {
const board = document.getElementById("board");
const stamp = document.getElementById("board-stamp");
try {
const res = await fetch(`${API}/api/cards`);
if (!res.ok) throw new Error();
const data = await res.json();
const cards = data.docs ?? [];
stamp.textContent = cards.length === 0
? "Empty"
: `${cards.length} filed`;
if (cards.length === 0) {
board.innerHTML =
'<div class="notice">В админке пока нет опубликованных карточек.</div>';
return;
}
board.innerHTML = cards.map((card, i) => {
const img = card.imageUrl
? `<img src="${absolutize(card.imageUrl)}" alt="${card.title.replace(/"/g, "&quot;")}" loading="${i < 3 ? "eager" : "lazy"}" />`
: "";
const comment = card.comment
? `<p class="cardComment">${card.comment.replace(/</g, "&lt;")}</p>`
: "";
return `<article class="photoCard">${img}<div class="caption"><span>#${String(i + 1).padStart(2, "0")}</span><strong>${card.title.replace(/</g, "&lt;")}</strong></div>${comment}</article>`;
}).join("");
} catch {
board.innerHTML =
'<div class="notice">Не удалось загрузить карточки.</div>';
stamp.textContent = "Error";
}
}
loadCards();
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
document.getElementById("board").addEventListener("click", (e) => {
const img = e.target.closest(".photoCard img");
if (!img) return;
lightboxImg.src = img.src;
lightboxImg.alt = img.alt;
lightbox.classList.add("open");
lightbox.setAttribute("aria-hidden", "false");
});
lightbox.addEventListener("click", () => {
lightbox.classList.remove("open");
lightbox.setAttribute("aria-hidden", "true");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && lightbox.classList.contains("open")) {
lightbox.classList.remove("open");
lightbox.setAttribute("aria-hidden", "true");
}
});
</script>
</body> </body>
</html> </html>