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>
</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>
` `
); );
} }
+5
View File
@@ -10,6 +10,7 @@ import {
readCards, readCards,
saveImage, saveImage,
storageMode, storageMode,
withWriteLock,
writeCards writeCards
} from "./store"; } from "./store";
@@ -251,6 +252,7 @@ const app = new Elysia()
} }
try { 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") || "");
@@ -261,6 +263,7 @@ const app = new Elysia()
: [nextCard, ...cards]; : [nextCard, ...cards];
await writeCards(nextCards); await writeCards(nextCards);
});
return redirect("/admin"); return redirect("/admin");
} catch (error) { } catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card."); return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
@@ -278,6 +281,7 @@ const app = new Elysia()
} }
try { 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();
@@ -288,6 +292,7 @@ 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) { } catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card."); return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
+19 -29
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";
@@ -20,30 +20,35 @@ const localCardsPath = path.join(localDataDir, "cards.json");
const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim()); const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
const isVercel = () => process.env.VERCEL === "1"; 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 const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> { export async function readCards(): Promise<BoardCard[]> {
if (hasBlobToken()) { 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;
} }
} }
@@ -52,7 +57,7 @@ export async function writeCards(cards: BoardCard[]) {
if (hasBlobToken()) { if (hasBlobToken()) {
await put(dataBlobPath, content, { await put(dataBlobPath, content, {
access: "private", access: "public",
contentType: "application/json", contentType: "application/json",
allowOverwrite: true allowOverwrite: true
}); });
@@ -119,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>