9de4992da5
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>
137 lines
3.6 KiB
TypeScript
137 lines
3.6 KiB
TypeScript
import { del, get, put } from "@vercel/blob";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export type BoardCard = {
|
|
id: string;
|
|
title: string;
|
|
comment: string;
|
|
imageUrl: string;
|
|
published: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
const dataBlobPath = "pozor/cards.json";
|
|
const localDataDir = path.resolve(".data");
|
|
const localUploadsDir = path.join(localDataDir, "uploads");
|
|
const localCardsPath = path.join(localDataDir, "cards.json");
|
|
|
|
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<BoardCard[]> {
|
|
if (hasBlobToken()) {
|
|
try {
|
|
const result = await get(dataBlobPath, { access: "private" });
|
|
if (!result?.stream) {
|
|
return [];
|
|
}
|
|
|
|
const text = await streamToText(result.stream);
|
|
const parsed = JSON.parse(text) 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 [];
|
|
}
|
|
}
|
|
|
|
export async function writeCards(cards: BoardCard[]) {
|
|
const content = JSON.stringify(cards, null, 2);
|
|
|
|
if (hasBlobToken()) {
|
|
await put(dataBlobPath, content, {
|
|
access: "private",
|
|
contentType: "application/json",
|
|
allowOverwrite: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
assertWritableLocalStorage();
|
|
await mkdir(localDataDir, { recursive: true });
|
|
await writeFile(localCardsPath, content, "utf8");
|
|
}
|
|
|
|
export async function saveImage(file: File) {
|
|
const safeName = safeFileName(file.name || "image");
|
|
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
|
|
|
if (hasBlobToken()) {
|
|
const blob = await put(pathname, file, {
|
|
access: "public",
|
|
contentType: file.type || "application/octet-stream"
|
|
});
|
|
return blob.url;
|
|
}
|
|
|
|
assertWritableLocalStorage();
|
|
await mkdir(localUploadsDir, { recursive: true });
|
|
const localName = `${Date.now()}-${safeName}`;
|
|
const localPath = path.join(localUploadsDir, localName);
|
|
await writeFile(localPath, Buffer.from(await file.arrayBuffer()));
|
|
return `/uploads/${localName}`;
|
|
}
|
|
|
|
export async function deleteRemoteImage(imageUrl: string) {
|
|
if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await del(imageUrl);
|
|
} catch {
|
|
// Image deletion should not block card edits.
|
|
}
|
|
}
|
|
|
|
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)
|
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
}
|
|
|
|
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");
|
|
}
|