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));
return pageShell(
@@ -52,6 +52,12 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
</aside>
</header>
${
options.message
? `<section class="noticeBanner">${escapeHtml(options.message)}</section>`
: ""
}
<section class="workbench">
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
<p class="stamp">New card</p>
@@ -87,6 +93,16 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
</section>
</section>
</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,
.editor,
.cardEditor,
.empty {
.empty,
.noticeBanner {
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-hard);
@@ -283,10 +300,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;
+17
View File
@@ -10,6 +10,7 @@ import {
readCards,
saveImage,
storageMode,
withWriteLock,
writeCards
} 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()
.use(
swagger({
@@ -246,6 +251,8 @@ const app = new Elysia()
return redirect("/admin");
}
try {
await withWriteLock(async () => {
const form = await request.formData();
const cards = await readCards();
const id = String(form.get("id") || "");
@@ -256,7 +263,11 @@ const app = new Elysia()
: [nextCard, ...cards];
await writeCards(nextCards);
});
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
}
}, {
detail: {
tags: ["admin"],
@@ -269,6 +280,8 @@ const app = new Elysia()
return redirect("/admin");
}
try {
await withWriteLock(async () => {
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
@@ -279,7 +292,11 @@ const app = new Elysia()
}
await writeCards(cards.filter((card) => card.id !== id));
});
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
}
}, {
detail: {
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 path from "node:path";
@@ -17,45 +17,54 @@ 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";
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[]> {
if (process.env.BLOB_READ_WRITE_TOKEN) {
try {
const result = await get(dataBlobPath, { access: "private" });
if (!result?.stream) {
return [];
}
if (hasBlobToken()) {
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
if (blobs.length === 0) return [];
const text = await streamToText(result.stream);
const parsed = JSON.parse(text) as BoardCard[];
const res = await fetch(blobs[0].url);
if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
try {
const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
} catch (err: any) {
if (err?.code === "ENOENT") return [];
throw err;
}
}
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",
access: "public",
contentType: "application/json",
allowOverwrite: true
});
return;
}
assertWritableLocalStorage();
await mkdir(localDataDir, { recursive: true });
await writeFile(localCardsPath, content, "utf8");
}
@@ -64,7 +73,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 +81,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 +90,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 +105,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)
@@ -104,18 +124,3 @@ export function publicCards(cards: BoardCard[]) {
function safeFileName(name: string) {
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";
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 = (
import.meta.env.API_URL ||
import.meta.env.PUBLIC_API_URL ||
"http://localhost:3001"
).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`;
---
@@ -90,31 +26,128 @@ const adminUrl = `${apiUrl}/admin`;
<p class="summary">тут только самое позорное.</p>
</div>
<aside class="caseTag" aria-label="Board status">
<span>{boardStamp}</span>
<span id="board-stamp">Loading...</span>
<strong>Elysia API</strong>
<a class="loginButton" href={adminUrl}>Login</a>
<small>admin / cards</small>
</aside>
</section>
<section class="board" aria-label="Published cards">
{boardItems.length === 0 && <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 class="board" aria-label="Published cards" id="board">
<div class="notice">Загрузка...</div>
</section>
</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>
</html>