13 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 c2e7b4704c Merge pull request #4 from dexxdbg/elysia-dev
Replace Payload with Elysia admin
2026-06-05 21:23:23 +03:00
dexx e3b0508871 Merge branch 'main' into elysia-dev 2026-06-05 21:22:00 +03:00
dexx e0d7641acc Merge pull request #3 from dexxdbg/vercel/react-server-components-cve-vu-j9owyy
Fix React Server Components CVE vulnerabilities
2026-06-05 12:54:47 +03:00
Vercel 45d5bfd2b6 Fix React Server Components CVE vulnerabilities
Updated dependencies to fix Next.js and React CVE vulnerabilities.

The fix-react2shell-next tool automatically updated the following packages to their secure versions:
- next
- react-server-dom-webpack
- react-server-dom-parcel  
- react-server-dom-turbopack

All package.json files have been scanned and vulnerable versions have been patched to the correct fixed versions based on the official React advisory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-06-05 00:08:12 +00:00
dexx acd9eaef49 Merge pull request #2 from dexxdbg/codex/initial-shame-board
configure board source in typescript
2026-06-05 03:05:07 +03:00
dexx 9217b7f139 Merge pull request #1 from dexxdbg/codex/initial-shame-board
[codex] initial shame board
2026-06-05 02:56:31 +03:00
4 changed files with 167 additions and 129 deletions
+10
View File
@@ -93,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>
`
);
}
+22 -17
View File
@@ -10,6 +10,7 @@ import {
readCards,
saveImage,
storageMode,
withWriteLock,
writeCards
} from "./store";
@@ -251,16 +252,18 @@ const app = new Elysia()
}
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 withWriteLock(async () => {
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);
await writeCards(nextCards);
});
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
@@ -278,16 +281,18 @@ const app = new Elysia()
}
try {
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
const existing = cards.find((card) => card.id === id);
await withWriteLock(async () => {
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));
await writeCards(cards.filter((card) => card.id !== id));
});
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
+20 -30
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";
@@ -20,30 +20,35 @@ const localCardsPath = path.join(localDataDir, "cards.json");
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 (hasBlobToken()) {
try {
const result = await get(dataBlobPath, { access: "private" });
if (!result?.stream) {
return [];
}
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[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
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 : [];
}
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;
}
}
@@ -52,7 +57,7 @@ export async function writeCards(cards: BoardCard[]) {
if (hasBlobToken()) {
await put(dataBlobPath, content, {
access: "private",
access: "public",
contentType: "application/json",
allowOverwrite: true
});
@@ -119,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>