188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
import { defineConfig, type Plugin } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
import { randomUUID } from "node:crypto";
|
||
import { existsSync } from "node:fs";
|
||
import { mkdir, writeFile } from "node:fs/promises";
|
||
import type { ServerResponse } from "node:http";
|
||
import { dirname, extname, join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
||
const maxStorageJsonBodyBytes = 260 * 1024 * 1024;
|
||
|
||
function localStorageApiPlugin(): Plugin {
|
||
return {
|
||
name: "nodedc-local-storage-api",
|
||
configureServer(server) {
|
||
server.middlewares.use(async (req, res, next) => {
|
||
const pathname = req.url?.split("?")[0];
|
||
|
||
if (req.method === "POST" && pathname === "/api/storage/upload") {
|
||
try {
|
||
const payload = await readJsonBody(req);
|
||
const result = await saveUploadedFile(payload);
|
||
sendJson(res, 200, result);
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && pathname === "/api/storage/data") {
|
||
try {
|
||
const payload = await readJsonBody(req);
|
||
await saveLauncherData(payload);
|
||
sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
next();
|
||
});
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(async (req, res, next) => {
|
||
const pathname = req.url?.split("?")[0];
|
||
|
||
if (req.method === "POST" && pathname === "/api/storage/upload") {
|
||
try {
|
||
const payload = await readJsonBody(req);
|
||
const result = await saveUploadedFile(payload);
|
||
sendJson(res, 200, result);
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && pathname === "/api/storage/data") {
|
||
try {
|
||
const payload = await readJsonBody(req);
|
||
await saveLauncherData(payload);
|
||
sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
next();
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
async function readJsonBody(req: NodeJS.ReadableStream) {
|
||
const chunks: Buffer[] = [];
|
||
let totalBytes = 0;
|
||
|
||
for await (const chunk of req) {
|
||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||
totalBytes += buffer.byteLength;
|
||
|
||
if (totalBytes > maxStorageJsonBodyBytes) {
|
||
throw new Error("Файл слишком большой для локального mock-storage");
|
||
}
|
||
|
||
chunks.push(buffer);
|
||
}
|
||
|
||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||
}
|
||
|
||
async function saveUploadedFile(payload: unknown) {
|
||
if (!isUploadPayload(payload)) {
|
||
throw new Error("Некорректный payload загрузки");
|
||
}
|
||
|
||
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
|
||
|
||
if (!match) {
|
||
throw new Error("Файл должен прийти data-url с base64");
|
||
}
|
||
|
||
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
|
||
const storedName = buildStoredFileName(payload.fileName, mimeType);
|
||
const fileBuffer = Buffer.from(match[2], "base64");
|
||
|
||
await Promise.all(
|
||
getWritableStorageRoots().map(async (storageRoot) => {
|
||
const uploadDir = join(storageRoot, "uploads");
|
||
await mkdir(uploadDir, { recursive: true });
|
||
await writeFile(join(uploadDir, storedName), fileBuffer);
|
||
})
|
||
);
|
||
|
||
return {
|
||
ok: true,
|
||
url: `/storage/uploads/${storedName}`,
|
||
fileName: storedName,
|
||
originalFileName: payload.fileName,
|
||
mimeType,
|
||
};
|
||
}
|
||
|
||
async function saveLauncherData(payload: unknown) {
|
||
await Promise.all(
|
||
getWritableStorageRoots().map(async (storageRoot) => {
|
||
await mkdir(storageRoot, { recursive: true });
|
||
await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||
})
|
||
);
|
||
}
|
||
|
||
function getWritableStorageRoots() {
|
||
const roots = [join(projectRoot, "public", "storage")];
|
||
const distRoot = join(projectRoot, "dist");
|
||
|
||
if (existsSync(distRoot)) {
|
||
roots.push(join(distRoot, "storage"));
|
||
}
|
||
|
||
return roots;
|
||
}
|
||
|
||
function buildStoredFileName(fileName: string, mimeType: string) {
|
||
const extension = extname(fileName) || extensionFromMimeType(mimeType);
|
||
const rawBase = fileName.slice(0, extension ? -extension.length : undefined);
|
||
const safeBase =
|
||
rawBase
|
||
.normalize("NFKD")
|
||
.replace(/[^\w.-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 80) || "upload";
|
||
|
||
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBase}${extension.toLowerCase()}`;
|
||
}
|
||
|
||
function extensionFromMimeType(mimeType: string) {
|
||
if (mimeType === "image/jpeg") return ".jpg";
|
||
if (mimeType === "image/png") return ".png";
|
||
if (mimeType === "image/gif") return ".gif";
|
||
if (mimeType === "image/webp") return ".webp";
|
||
if (mimeType === "video/mp4") return ".mp4";
|
||
if (mimeType === "video/webm") return ".webm";
|
||
if (mimeType === "video/quicktime") return ".mov";
|
||
return "";
|
||
}
|
||
|
||
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||
res.statusCode = statusCode;
|
||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||
res.end(JSON.stringify(payload));
|
||
}
|
||
|
||
function isUploadPayload(payload: unknown): payload is { fileName: string; mimeType: string; dataUrl: string } {
|
||
if (!payload || typeof payload !== "object") return false;
|
||
|
||
const candidate = payload as { fileName?: unknown; mimeType?: unknown; dataUrl?: unknown };
|
||
|
||
return typeof candidate.fileName === "string" && typeof candidate.mimeType === "string" && typeof candidate.dataUrl === "string";
|
||
}
|
||
|
||
export default defineConfig({
|
||
plugins: [react(), localStorageApiPlugin()],
|
||
});
|