135 lines
3.5 KiB
TypeScript
135 lines
3.5 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { mkdir, unlink, writeFile } from "fs/promises";
|
|
import path from "path";
|
|
|
|
const allowedExtensions = new Set([
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".webp",
|
|
".svg",
|
|
".pdf",
|
|
".docx",
|
|
".doc",
|
|
".xlsx",
|
|
".xls",
|
|
".pptx",
|
|
".zip",
|
|
".mp4",
|
|
".mov"
|
|
]);
|
|
|
|
const allowedMimes = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/webp",
|
|
"image/svg+xml",
|
|
"application/pdf",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
"application/zip",
|
|
"video/mp4",
|
|
"video/quicktime"
|
|
]);
|
|
|
|
export function getUploadDir() {
|
|
return process.env.UPLOAD_DIR ?? path.join(process.cwd(), "storage", "uploads");
|
|
}
|
|
|
|
export function getUploadMaxBytes() {
|
|
const maxMb = Number(process.env.UPLOAD_MAX_MB ?? 25);
|
|
return maxMb * 1024 * 1024;
|
|
}
|
|
|
|
function hasMagicBytes(buffer: Buffer, mime: string, extension: string) {
|
|
if (mime === "image/png") {
|
|
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
}
|
|
|
|
if (mime === "image/jpeg") {
|
|
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
|
}
|
|
|
|
if (mime === "image/webp") {
|
|
return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
|
|
}
|
|
|
|
if (mime === "application/pdf") {
|
|
return buffer.subarray(0, 4).toString("ascii") === "%PDF";
|
|
}
|
|
|
|
if (mime === "video/mp4" || mime === "video/quicktime") {
|
|
return buffer.subarray(4, 8).toString("ascii") === "ftyp";
|
|
}
|
|
|
|
if (extension === ".zip" || extension === ".docx" || extension === ".xlsx" || extension === ".pptx") {
|
|
return buffer[0] === 0x50 && buffer[1] === 0x4b;
|
|
}
|
|
|
|
if (extension === ".doc" || extension === ".xls") {
|
|
return buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
|
|
}
|
|
|
|
if (mime === "image/svg+xml") {
|
|
const start = buffer.subarray(0, 512).toString("utf8").trimStart().toLowerCase();
|
|
return start.startsWith("<svg") || start.startsWith("<?xml");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function validateAndStoreUpload(file: File) {
|
|
const originalName = file.name;
|
|
const extension = path.extname(originalName).toLowerCase();
|
|
const mime = file.type;
|
|
const maxBytes = getUploadMaxBytes();
|
|
|
|
if (!allowedExtensions.has(extension)) {
|
|
throw new Error("Tipo de arquivo nao permitido.");
|
|
}
|
|
|
|
if (!allowedMimes.has(mime)) {
|
|
throw new Error("MIME do arquivo nao permitido.");
|
|
}
|
|
|
|
if (file.size <= 0) {
|
|
throw new Error("Arquivo vazio.");
|
|
}
|
|
|
|
if (file.size > maxBytes) {
|
|
throw new Error(`Arquivo maior que o limite de ${Math.round(maxBytes / 1024 / 1024)} MB.`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
|
|
if (!hasMagicBytes(buffer, mime, extension)) {
|
|
throw new Error("Conteudo do arquivo nao confere com o tipo informado.");
|
|
}
|
|
|
|
const uploadDir = getUploadDir();
|
|
await mkdir(uploadDir, { recursive: true });
|
|
|
|
const storedName = `${randomUUID()}${extension}`;
|
|
const storedPath = path.join(uploadDir, storedName);
|
|
|
|
await writeFile(storedPath, buffer);
|
|
|
|
return {
|
|
originalName,
|
|
storedPath,
|
|
mime,
|
|
size: file.size
|
|
};
|
|
}
|
|
|
|
export async function removeStoredFile(filePath: string) {
|
|
try {
|
|
await unlink(filePath);
|
|
} catch {
|
|
// A ausencia do arquivo nao deve impedir a remocao do registro.
|
|
}
|
|
}
|