- Auth: TOTP replay protection with counter tracking, constant-time backup code comparison, atomic lockout increment, per-token logout - Invoices/PDFs: net-based VAT calculation, dangerous URL scheme stripping in cleanQuillHtml, orders-pdf error handling - Orders: reject item changes on status transition, cascading delete cleanup, take:1 with orderBy - Projects: atomic rename collision handling, MIME/extension validation, empty customer name rejection - Attendance: Czech public holiday awareness in frontend fund calculation, leave_hours 0 handling, invalid date NaN guard, bounded per-month queries in workfund - Users/Admin: profile audit logging + password validation, session revocation guard, session ID validation, dashboard DB aggregation, soft-deleted record protection in scope templates - Frontend: FormField label linkage, Pagination ARIA, error handling in OrderConfirmationModal, 401 propagation, GPS emoji hidden from screen readers, table sort state fix, geolocation race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive minimal body, CompanySettings ref mutation fix, OfferDetail unlock abort, AttendanceBalances combined fetches - Utils: env validation, Puppeteer concurrency mutex, invoice alert cron cleanup on shutdown, body limit alignment, TOTP error logging, trustProxy from env, symlink rejection, rate cache Map usage Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
728 lines
18 KiB
TypeScript
728 lines
18 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import { pipeline } from "stream/promises";
|
|
import { config } from "../config/env";
|
|
import { localDateStr, localTimeStr } from "../utils/date";
|
|
|
|
const FileType = require("file-type") as typeof import("file-type");
|
|
|
|
const BLOCKED_EXTENSIONS = new Set([
|
|
"exe",
|
|
"bat",
|
|
"sh",
|
|
"php",
|
|
"htaccess",
|
|
"env",
|
|
"cmd",
|
|
"com",
|
|
"msi",
|
|
"ps1",
|
|
"vbs",
|
|
"vbe",
|
|
"js",
|
|
"ws",
|
|
"wsf",
|
|
"scr",
|
|
"pif",
|
|
"jar",
|
|
"reg",
|
|
]);
|
|
|
|
const SUSPICIOUS_MIMES = [
|
|
"application/x-executable",
|
|
"application/x-msdos-program",
|
|
"application/x-dosexec",
|
|
"application/x-msdownload",
|
|
];
|
|
|
|
const MIME_MAP: Record<string, string> = {
|
|
pdf: "application/pdf",
|
|
doc: "application/msword",
|
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
xls: "application/vnd.ms-excel",
|
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
ppt: "application/vnd.ms-powerpoint",
|
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
zip: "application/zip",
|
|
rar: "application/x-rar-compressed",
|
|
"7z": "application/x-7z-compressed",
|
|
tar: "application/x-tar",
|
|
gz: "application/gzip",
|
|
png: "image/png",
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
gif: "image/gif",
|
|
bmp: "image/bmp",
|
|
svg: "image/svg+xml",
|
|
webp: "image/webp",
|
|
ico: "image/x-icon",
|
|
tif: "image/tiff",
|
|
tiff: "image/tiff",
|
|
mp3: "audio/mpeg",
|
|
wav: "audio/wav",
|
|
mp4: "video/mp4",
|
|
avi: "video/x-msvideo",
|
|
mkv: "video/x-matroska",
|
|
mov: "video/quicktime",
|
|
txt: "text/plain",
|
|
csv: "text/csv",
|
|
html: "text/html",
|
|
htm: "text/html",
|
|
xml: "application/xml",
|
|
json: "application/json",
|
|
dwg: "application/acad",
|
|
dxf: "application/dxf",
|
|
step: "application/step",
|
|
stp: "application/step",
|
|
iges: "application/iges",
|
|
igs: "application/iges",
|
|
};
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
type: "file" | "folder";
|
|
modified: string;
|
|
is_symlink: boolean;
|
|
link_target?: string;
|
|
size?: number;
|
|
size_formatted?: string;
|
|
extension?: string;
|
|
item_count?: number;
|
|
}
|
|
|
|
interface ListFilesResult {
|
|
path: string;
|
|
items: FileItem[];
|
|
breadcrumb: string[];
|
|
full_path: string;
|
|
}
|
|
|
|
interface DownloadResult {
|
|
filePath: string;
|
|
fileName: string;
|
|
mime: string;
|
|
}
|
|
|
|
export class NasFileManager {
|
|
private readonly basePath: string;
|
|
|
|
constructor() {
|
|
this.basePath = path.resolve(config.nas.path).replace(/\\/g, "/");
|
|
}
|
|
|
|
public isConfigured(): boolean {
|
|
if (!this.basePath) return false;
|
|
try {
|
|
return (
|
|
fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory()
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public createProjectFolder(
|
|
projectNumber: string,
|
|
projectName: string,
|
|
): boolean {
|
|
if (!this.isConfigured()) return false;
|
|
|
|
const folderName = this.buildFolderName(projectNumber, projectName);
|
|
const fullPath = this.basePath + "/" + folderName;
|
|
|
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
fs.mkdirSync(fullPath, { recursive: true, mode: 0o775 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async deleteProjectFolder(projectNumber: string): Promise<boolean> {
|
|
if (!this.isConfigured()) return false;
|
|
|
|
const folderPath = this.findProjectFolder(projectNumber);
|
|
if (folderPath === null) return true;
|
|
|
|
try {
|
|
await fs.promises.rm(folderPath, { recursive: true, force: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public projectFolderExists(projectNumber: string): boolean {
|
|
return this.findProjectFolder(projectNumber) !== null;
|
|
}
|
|
|
|
public renameProjectFolder(projectNumber: string, newName: string): boolean {
|
|
if (!this.isConfigured()) return false;
|
|
|
|
const currentPath = this.findProjectFolder(projectNumber);
|
|
if (currentPath === null) return false;
|
|
|
|
const newFolderName = this.buildFolderName(projectNumber, newName);
|
|
const newPath = this.basePath + "/" + newFolderName;
|
|
|
|
if (currentPath === newPath) return true;
|
|
|
|
try {
|
|
fs.renameSync(currentPath, newPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public listFiles(
|
|
projectNumber: string,
|
|
subPath: string = "",
|
|
): ListFilesResult | null {
|
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
|
if (dirPath === null) return null;
|
|
|
|
try {
|
|
const stat = fs.lstatSync(dirPath);
|
|
if (!stat.isDirectory()) return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(dirPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const items: FileItem[] = [];
|
|
for (const entry of entries) {
|
|
const fullPath = dirPath + "/" + entry;
|
|
|
|
let lstat: fs.Stats;
|
|
try {
|
|
lstat = fs.lstatSync(fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const isLink = lstat.isSymbolicLink();
|
|
// For symlinks, we need to check if target is dir
|
|
let isDir: boolean;
|
|
if (isLink) {
|
|
try {
|
|
isDir = fs.statSync(fullPath).isDirectory();
|
|
} catch {
|
|
isDir = false;
|
|
}
|
|
} else {
|
|
isDir = lstat.isDirectory();
|
|
}
|
|
|
|
const modified = lstat.mtime;
|
|
const modifiedStr = `${localDateStr(modified)} ${localTimeStr(modified)}`;
|
|
|
|
const item: FileItem = {
|
|
name: entry,
|
|
type: isDir ? "folder" : "file",
|
|
modified: modifiedStr,
|
|
is_symlink: isLink,
|
|
};
|
|
|
|
if (isLink) {
|
|
try {
|
|
const target = fs.readlinkSync(fullPath);
|
|
item.link_target = target.replace(/\//g, "\\");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (isDir) {
|
|
item.item_count = this.countItems(fullPath);
|
|
} else {
|
|
const size = lstat.size;
|
|
item.size = size;
|
|
item.size_formatted = this.formatFileSize(size);
|
|
item.extension = path.extname(entry).slice(1).toLowerCase();
|
|
}
|
|
|
|
items.push(item);
|
|
}
|
|
|
|
// Sort: folders first, then files, both alphabetically (natural sort)
|
|
items.sort((a, b) => {
|
|
if (a.type !== b.type) {
|
|
return a.type === "folder" ? -1 : 1;
|
|
}
|
|
return a.name.localeCompare(b.name, undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
});
|
|
|
|
const breadcrumb: string[] = [""];
|
|
if (subPath !== "") {
|
|
const parts = subPath
|
|
.replace(/\\/g, "/")
|
|
.replace(/^\/+|\/+$/g, "")
|
|
.split("/");
|
|
for (const part of parts) {
|
|
breadcrumb.push(part);
|
|
}
|
|
}
|
|
|
|
let realDirPath: string;
|
|
try {
|
|
realDirPath = fs.realpathSync(dirPath);
|
|
} catch {
|
|
realDirPath = dirPath;
|
|
}
|
|
|
|
return {
|
|
path: subPath,
|
|
items,
|
|
breadcrumb,
|
|
full_path: realDirPath.replace(/\//g, "\\"),
|
|
};
|
|
}
|
|
|
|
public async uploadFile(
|
|
projectNumber: string,
|
|
subPath: string,
|
|
fileStream: NodeJS.ReadableStream,
|
|
fileName: string,
|
|
): Promise<string | null> {
|
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
|
if (dirPath === null) {
|
|
return "Cílová složka neexistuje";
|
|
}
|
|
|
|
try {
|
|
const stat = await fs.promises.stat(dirPath);
|
|
if (!stat.isDirectory()) {
|
|
return "Cílová složka neexistuje";
|
|
}
|
|
} catch {
|
|
return "Cílová složka neexistuje";
|
|
}
|
|
|
|
const originalName = path.basename(fileName);
|
|
let safeName = this.sanitizeFilename(originalName);
|
|
if (safeName === "") {
|
|
return "Neplatný název souboru";
|
|
}
|
|
|
|
const ext = path.extname(safeName).slice(1).toLowerCase();
|
|
if (BLOCKED_EXTENSIONS.has(ext)) {
|
|
return "Tento typ souboru není povolen";
|
|
}
|
|
|
|
const tempPath = path.join(
|
|
require("os").tmpdir(),
|
|
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
|
|
try {
|
|
await pipeline(fileStream, fs.createWriteStream(tempPath));
|
|
} catch {
|
|
await fs.promises.unlink(tempPath).catch(() => {});
|
|
return "Nepodařilo se uložit soubor";
|
|
}
|
|
|
|
try {
|
|
const typeResult = await FileType.fromFile(tempPath);
|
|
if (typeResult) {
|
|
if (this.isSuspiciousMime(typeResult.mime)) {
|
|
await fs.promises.unlink(tempPath).catch(() => {});
|
|
return "Obsah souboru neodpovídá jeho příponě";
|
|
}
|
|
const expectedMime = ext ? MIME_MAP[ext] : null;
|
|
if (expectedMime && typeResult.mime !== expectedMime) {
|
|
await fs.promises.unlink(tempPath).catch(() => {});
|
|
return "Obsah souboru neodpovídá jeho příponě";
|
|
}
|
|
}
|
|
} catch {
|
|
// If file-type fails, continue without MIME check
|
|
}
|
|
|
|
let destPath = dirPath + "/" + safeName;
|
|
|
|
// Attempt atomic rename; if destination exists, append counter
|
|
let renamed = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 1000;
|
|
do {
|
|
try {
|
|
await fs.promises.rename(tempPath, destPath);
|
|
renamed = true;
|
|
break;
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e.code === "EEXIST") {
|
|
const base = path.basename(safeName, ext ? "." + ext : "");
|
|
attempts++;
|
|
safeName = base + "_" + attempts + (ext ? "." + ext : "");
|
|
destPath = dirPath + "/" + safeName;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} while (!renamed && attempts < maxAttempts);
|
|
|
|
if (!renamed) {
|
|
await fs.promises.unlink(tempPath).catch(() => {});
|
|
return "Nepodařilo se uložit soubor";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public downloadFile(
|
|
projectNumber: string,
|
|
filePath: string,
|
|
): DownloadResult | null {
|
|
const fullPath = this.resolveProjectPath(projectNumber, filePath);
|
|
if (fullPath === null) return null;
|
|
|
|
try {
|
|
const stat = fs.lstatSync(fullPath);
|
|
if (!stat.isFile()) return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const fileName = path.basename(fullPath);
|
|
const ext = path.extname(fileName).slice(1).toLowerCase();
|
|
const mime = MIME_MAP[ext] || "application/octet-stream";
|
|
|
|
return {
|
|
filePath: fullPath,
|
|
fileName,
|
|
mime,
|
|
};
|
|
}
|
|
|
|
public async deleteItem(
|
|
projectNumber: string,
|
|
filePath: string,
|
|
): Promise<string | null> {
|
|
if (
|
|
filePath === "" ||
|
|
filePath === "/" ||
|
|
filePath === "." ||
|
|
filePath === "./"
|
|
) {
|
|
return "Nelze smazat kořenovou složku projektu";
|
|
}
|
|
|
|
const fullPath = this.resolveProjectPath(projectNumber, filePath);
|
|
if (fullPath === null) {
|
|
return "Neplatná cesta";
|
|
}
|
|
|
|
let isDir: boolean;
|
|
try {
|
|
const stat = await fs.promises.lstat(fullPath);
|
|
isDir = stat.isDirectory();
|
|
} catch {
|
|
return "Soubor nebo složka neexistuje";
|
|
}
|
|
|
|
try {
|
|
if (isDir) {
|
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
|
} else {
|
|
await fs.promises.unlink(fullPath);
|
|
}
|
|
} catch {
|
|
return isDir
|
|
? "Nepodařilo se smazat složku"
|
|
: "Nepodařilo se smazat soubor";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public async moveItem(
|
|
projectNumber: string,
|
|
fromPath: string,
|
|
toPath: string,
|
|
): Promise<string | null> {
|
|
if (
|
|
fromPath === "" ||
|
|
fromPath === "/" ||
|
|
fromPath === "." ||
|
|
fromPath === "./"
|
|
) {
|
|
return "Nelze přesunout kořenovou složku";
|
|
}
|
|
|
|
const fullFrom = this.resolveProjectPath(projectNumber, fromPath);
|
|
const fullTo = this.resolveProjectPath(projectNumber, toPath);
|
|
|
|
if (fullFrom === null || fullTo === null) {
|
|
return "Neplatná cesta";
|
|
}
|
|
|
|
try {
|
|
await fs.promises.stat(fullFrom);
|
|
} catch {
|
|
return "Zdrojový soubor neexistuje";
|
|
}
|
|
|
|
// Case-insensitive FS (Windows) — allow case-only rename
|
|
const sameFile =
|
|
fullFrom.replace(/\\/g, "/").toLowerCase() ===
|
|
fullTo.replace(/\\/g, "/").toLowerCase();
|
|
|
|
if (!sameFile) {
|
|
try {
|
|
await fs.promises.stat(fullTo);
|
|
return "Cílový soubor již existuje";
|
|
} catch {
|
|
// target does not exist, continue
|
|
}
|
|
}
|
|
|
|
const targetName = path.basename(toPath);
|
|
if (this.sanitizeFilename(targetName) !== targetName) {
|
|
return "Neplatný cílový název";
|
|
}
|
|
|
|
try {
|
|
await fs.promises.rename(fullFrom, fullTo);
|
|
} catch (err: unknown) {
|
|
if (
|
|
err instanceof Error &&
|
|
"code" in err &&
|
|
(err as NodeJS.ErrnoException).code === "EXDEV"
|
|
) {
|
|
return "Přesun mezi různými disky není podporován";
|
|
}
|
|
return "Nepodařilo se přesunout soubor";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public async createFolder(
|
|
projectNumber: string,
|
|
subPath: string,
|
|
folderName: string,
|
|
): Promise<string | null> {
|
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
|
if (dirPath === null) {
|
|
return "Nadřazená složka neexistuje";
|
|
}
|
|
|
|
try {
|
|
const stat = await fs.promises.lstat(dirPath);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
return "Nadřazená složka neexistuje";
|
|
}
|
|
} catch {
|
|
return "Nadřazená složka neexistuje";
|
|
}
|
|
|
|
const safeName = this.sanitizeFilename(folderName);
|
|
if (safeName === "") {
|
|
return "Neplatný název složky";
|
|
}
|
|
|
|
const newPath = dirPath + "/" + safeName;
|
|
try {
|
|
await fs.promises.stat(newPath);
|
|
return "Složka s tímto názvem již existuje";
|
|
} catch {
|
|
// does not exist, continue
|
|
}
|
|
|
|
try {
|
|
await fs.promises.mkdir(newPath, { mode: 0o775 });
|
|
} catch {
|
|
return "Nepodařilo se vytvořit složku";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public sanitizeFilename(name: string): string {
|
|
let safe = path.basename(name);
|
|
safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, "");
|
|
safe = safe.replace(/^[. ]+|[. ]+$/g, "");
|
|
|
|
if ([...safe].length > 255) {
|
|
const ext = path.extname(safe).slice(1);
|
|
const base = path.basename(safe, ext ? "." + ext : "");
|
|
const maxBase = 250 - [...ext].length;
|
|
const trimmedBase = [...base].slice(0, maxBase).join("");
|
|
safe = ext ? trimmedBase + "." + ext : trimmedBase;
|
|
}
|
|
|
|
return safe;
|
|
}
|
|
|
|
// --- Private helpers ---
|
|
|
|
private findProjectFolder(projectNumber: string): string | null {
|
|
if (!this.isConfigured()) return null;
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(this.basePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const prefix = projectNumber + "_";
|
|
for (const entry of entries) {
|
|
if (entry.startsWith(prefix)) {
|
|
const fullPath = this.basePath + "/" + entry;
|
|
try {
|
|
if (fs.statSync(fullPath).isDirectory()) {
|
|
return fullPath;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private buildFolderName(projectNumber: string, projectName: string): string {
|
|
let safeNum = projectNumber.replace(/[^\p{L}\p{N}_\-.]/gu, "");
|
|
safeNum = safeNum.replace(/^\.+|\.+$/g, "").trim();
|
|
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, "");
|
|
safe = safe.trim().replace(/ /g, "_").replace(/_+/g, "_");
|
|
if ([...safe].length > 200) {
|
|
safe = [...safe].slice(0, 200).join("");
|
|
}
|
|
return safeNum + "_" + safe;
|
|
}
|
|
|
|
private resolveProjectPath(
|
|
projectNumber: string,
|
|
subPath: string,
|
|
): string | null {
|
|
const folderPath = this.findProjectFolder(projectNumber);
|
|
if (folderPath === null) return null;
|
|
|
|
if (subPath === "" || subPath === "/") {
|
|
return folderPath;
|
|
}
|
|
|
|
// Basic path traversal protection
|
|
if (subPath.includes("\0") || subPath.includes("..")) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
|
|
// Reject explicit current-directory references (defense-in-depth for destructive ops)
|
|
if (normalized === "." || normalized === "./") {
|
|
return null;
|
|
}
|
|
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
|
|
|
|
// Verify candidate is within project folder
|
|
const normalFolder = folderPath.replace(/\\/g, "/");
|
|
if (
|
|
!candidate.startsWith(normalFolder + "/") &&
|
|
candidate !== normalFolder
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Check for symlinks in path components
|
|
if (fs.existsSync(candidate)) {
|
|
if (!this.walkAndRejectSymlinks(candidate, normalFolder)) {
|
|
return null;
|
|
}
|
|
} else {
|
|
// For new files/folders — check parent
|
|
const parentDir = path.dirname(candidate);
|
|
if (fs.existsSync(parentDir)) {
|
|
if (!this.walkAndRejectSymlinks(parentDir, normalFolder)) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
|
|
const normalFull = fullPath.replace(/\\/g, "/");
|
|
const normalBase = basePath.replace(/\\/g, "/");
|
|
|
|
if (!normalFull.startsWith(normalBase)) {
|
|
return false;
|
|
}
|
|
|
|
const relative = normalFull.slice(normalBase.length);
|
|
if (!relative) return true; // same as base
|
|
|
|
const parts = relative.split("/").filter(Boolean);
|
|
let current = normalBase;
|
|
|
|
for (const part of parts) {
|
|
current = current + "/" + part;
|
|
try {
|
|
const lstat = fs.lstatSync(current);
|
|
if (lstat.isSymbolicLink()) {
|
|
return false;
|
|
}
|
|
} catch {
|
|
// Path component doesn't exist yet, that's OK
|
|
break;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private countItems(dirPath: string): number {
|
|
try {
|
|
const entries = fs.readdirSync(dirPath);
|
|
return entries.length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) {
|
|
return bytes + " B";
|
|
}
|
|
if (bytes < 1048576) {
|
|
return Math.round((bytes / 1024) * 10) / 10 + " KB";
|
|
}
|
|
if (bytes < 1073741824) {
|
|
return Math.round((bytes / 1048576) * 10) / 10 + " MB";
|
|
}
|
|
return Math.round((bytes / 1073741824) * 10) / 10 + " GB";
|
|
}
|
|
|
|
private isSuspiciousMime(mime: string): boolean {
|
|
if (SUSPICIOUS_MIMES.includes(mime)) {
|
|
return true;
|
|
}
|
|
|
|
// PHP files
|
|
if (mime.includes("php") || mime.includes("x-httpd")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|