Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Валидация файлов на клиенте: типизация Drag & Drop, парсинг MIME-типов и надежная проверка данных в TypeScript

Тимофей Ищенко

Коротко: Руководство по валидации файлов на клиенте в TypeScript: обработка событий Drag & Drop, разбор File.type, сопоставление accept и сигнатур Magic Bytes.

Организация загрузки файлов в современных веб-приложениях — частый источник трудноуловимых багов. Разработчики нередко полагаются на атрибут accept у нативного инпута или всецело доверяют свойству file.type, сталкиваясь с непредсказуемым поведением браузеров на разных операционных системах. Пользователи перетаскивают файлы без расширений, загружают архивы вместо изображений или сталкиваются с зависанием интерфейса при попытке обработать тяжелый файл.

Клиентская валидация решает ключевую инженерную задачу: обеспечивает мгновенный UX-отклик и экономит исходящий трафик пользователя, предотвращая отправку заведомо неподходящих данных по сети. Построим типобезопасный и устойчивый к краевым кейсам пайплайн валидации файлов на TypeScript, способный надежно работать как с классическими формами, так и с интерфейсами Drag & Drop.


Анатомия загрузки файлов: <input type="file"> против HTML5 Drag & Drop API

В браузерной среде файл попадает в JavaScript-контекст двумя основными путями: через стандартный элемент <input type="file"> или с помощью Drag & Drop API. Несмотря на то что конечным результатом в обоих случаях является коллекция объектов File, механика их получения и жизненный цикл событий принципиально различаются.

Поток A: <input type="file">
  [ Диалог ОС ] -> [ ChangeEvent ] -> [ HTMLInputElement.files ] -> [ FileList ]

Поток B: HTML5 Drag & Drop API
  [ dragenter ] -> [ dragover (preventDefault) ] -> [ drop ] -> [ DataTransfer.files ] -> [ FileList ]

Жизненный цикл событий Drag & Drop

При работе с Drag & Drop необходимо явно управлять четырьмя событиями: dragenter, dragover, dragleave и drop. По умолчанию браузер стремится открыть перетаскиваемый файл прямо во вкладке (например, отобразить PDF или изображение), уводя пользователя со страницы приложения.

Чтобы перехватить управление, критически важно вызывать e.preventDefault() и e.stopPropagation() внутри обработчиков dragover и drop.

function handleDragOver(event: DragEvent): void {
  event.preventDefault();
  event.stopPropagation();
  
  if (event.dataTransfer) {
    event.dataTransfer.dropEffect = 'copy';
  }
}

Ограничения безопасности Drag Data Store

Во время выполнения событий dragenter и dragover браузер переводит внутреннее хранилище перетаскиваемых данных в защищенный режим (Protected Mode). Это сделано для того, чтобы скрипт на открытой странице не мог прочитать конфиденциальные данные файлов, которые пользователь просто проносит курсором над окном браузера.

  • В событиях dragenter и dragover: свойство dataTransfer.files будет пустым (FileList с длиной 0). Проверить размер, имя или бинарную сигнатуру файла на этом этапе невозможно. Единственное, к чему есть доступ — массив строк dataTransfer.types. При перетаскивании файлов этот массив содержит маркерное значение 'Files'.
  • В событии drop: хранилище переходит в режим чтения (Read-only Mode), открывая полный доступ к объектам в dataTransfer.files и dataTransfer.items.
function isFileDragging(event: DragEvent): boolean {
  if (!event.dataTransfer?.types) return false;
  // Спецификация WHATWG: наличие 'Files' указывает на перетаскивание файлов
  return Array.from(event.dataTransfer.types).includes('Files');
}

Ловушки и ограничения свойства File.type

Свойство File.type возвращает строку с медиа-типом (MIME). Однако опираться исключительно на него при валидации нельзя.

Почему File.type бывает пустым

Согласно спецификации WHATWG, если браузер не может определить MIME-тип, свойство File.type возвращает пустую строку "".

Браузер не парсит бинарный состав файла при инициализации объекта File. Он обращается к локальной операционной системе и сопоставляет расширение файла с системным реестром типов ОС (например, реестр Windows или MIME-базы в Linux/macOS). Если:

  1. У файла отсутствует расширение (например, .dockerignore, LICENSE);
  2. В операционной системе пользователя отсутствует привязка для конкретного формата (часто случается с .webp, .heic, .csv, специфическими CAD-файлами);
  3. Файл сформирован динамически на стороне ОС с нестандартными метаданными,

то file.type вернет "". Полагаясь только на прямое условие if (file.type === 'text/csv'), вы неизбежно заблокируете валидных пользователей.

Роль атрибута accept

Атрибут accept у тега <input type="file"> — это исключительно UI-подсказка для диалогового окна операционной системы.

<input 
  type="file" 
  accept="image/png, image/jpeg, .webp, application/pdf" 
/>

Пользователь может обойти это ограничение, переключив фильтр в диалоге ОС на «Все файлы (.)», либо просто перетащив файл любого формата в окно браузера через Drag & Drop. Следовательно, логика сопоставления шаблонов accept должна быть полностью продублирована программно в TypeScript.


Проектирование строгой типизации в TypeScript

Для построения расширяемой системы валидации опишем спецификацию правил, возможные ошибки и результат валидации с использованием размеченных объединений (Discriminated Unions).

Определение моделей и типов ошибок

export type MimePattern = 
  | `${string}/${string}` 
  | `.${string}` 
  | 'image/*' 
  | 'video/*' 
  | 'audio/*';

export interface FileValidationRules {
  /** Максимальный размер файла в байтах */
  maxSizeBytes?: number;
  /** Минимальный размер файла в байтах */
  minSizeBytes?: number;
  /** Список разрешенных MIME-типов, масок или расширений */
  accept?: readonly MimePattern[];
  /** Максимальное количество файлов (для multi-upload) */
  maxFiles?: number;
}

export type FileErrorCode = 
  | 'FILE_TOO_LARGE'
  | 'FILE_TOO_SMALL'
  | 'INVALID_TYPE'
  | 'TOO_MANY_FILES'
  | 'SIGNATURE_MISMATCH';

export interface FileValidationError {
  code: FileErrorCode;
  message: string;
  file: File;
}

export interface ValidationSuccess {
  valid: true;
  files: File[];
}

export interface ValidationFailure {
  valid: false;
  acceptedFiles: File[];
  rejectedFiles: Array<{
    file: File;
    errors: FileValidationError[];
  }>;
}

export type ValidationResult = ValidationSuccess | ValidationFailure;

Реализация надежного пайплайна валидации

Полноценная клиентская валидация включает в себя три рубежа:

  1. Проверка метаданных (размер и лимиты количества).
  2. Гибкое сопоставление типов (MIME + fallback по расширению).
  3. Верификация сигнатуры (Magic Bytes) для критичных форматов.

Утилита матчинга MIME-типов и расширений

Разберем логику соответствия файла переданному правилу accept. Функция учитывает точные совпадения (image/png), подстановочные знаки (image/*) и явные расширения (.pdf).

/**
 * Извлекает нормализованное расширение файла из его имени.
 */
export function extractExtension(filename: string): string {
  const parts = filename.split('.');
  if (parts.length <= 1) return '';
  return `.${parts.pop()!.toLowerCase()}`;
}

/**
 * Проверяет соответствие файла заданному набору масок/типов.
 */
export function isMimeMatched(file: File, acceptList: readonly MimePattern[]): boolean {
  if (acceptList.length === 0) return true;

  const fileType = file.type.toLowerCase();
  const fileExt = extractExtension(file.name);

  return acceptList.some((pattern) => {
    const normalizedPattern = pattern.toLowerCase().trim();

    // 1. Проверка по расширению (например, ".xlsx")
    if (normalizedPattern.startsWith('.')) {
      return fileExt === normalizedPattern;
    }

    // 2. Проверка по wildcards (например, "image/*")
    if (normalizedPattern.endsWith('/*')) {
      const group = normalizedPattern.split('/')[0];
      return fileType.startsWith(`${group}/`);
    }

    // 3. Прямое соответствие MIME (например, "application/pdf")
    if (fileType) {
      return fileType === normalizedPattern;
    }

    // Fallback: если file.type пуст, но паттерн MIME задан
    return false;
  });
}

Проверка Magic Bytes (сигнатур файлов)

Пользователь может случайно или намеренно переименовать исполняемый файл в document.pdf. Свойство file.type при этом может заполниться как application/pdf исключительно на основе расширения.

Для отсечения таких файлов до начала загрузки можно прочитать первые байты содержимого через метод Blob.prototype.arrayBuffer().

Формат Magic Bytes (Hex) Смещение
PNG 89 50 4E 47 0
JPEG FF D8 FF 0
PDF 25 50 44 46 (%PDF) 0
GIF 47 49 46 38 (GIF8) 0
ZIP / DOCX / XLSX 50 4B 03 04 0
type SupportedSignature = 'png' | 'jpeg' | 'pdf' | 'zip';

const SIGNATURES: Record<SupportedSignature, number[][]> = {
  png: [[0x89, 0x50, 0x4E, 0x47]],
  jpeg: [[0xFF, 0xD8, 0xFF]],
  pdf: [[0x25, 0x50, 0x44, 0x46]],
  zip: [[0x50, 0x4B, 0x03, 0x04]], // Сигнатура PK-архивов (включая DOCX, XLSX)
};

/**
 * Асинхронно проверяет первые байты файла на соответствие известным сигнатурам.
 */
export async function verifyFileSignature(
  file: File, 
  expectedFormat: SupportedSignature
): Promise<boolean> {
  const signatures = SIGNATURES[expectedFormat];
  if (!signatures) return true;

  // Считываем только первые 8 байт для минимизации нагрузки на память
  const slice = file.slice(0, 8);
  const buffer = await slice.arrayBuffer();
  const bytes = new Uint8Array(buffer);

  return signatures.some((sig) => 
    sig.every((expectedByte, index) => bytes[index] === expectedByte)
  );
}

Комплексный валидатор коллекции файлов

Объединим проверки в единый конвейер:

export async function validateFiles(
  files: File[],
  rules: FileValidationRules
): Promise<ValidationResult> {
  const acceptedFiles: File[] = [];
  const rejectedFiles: Array<{ file: File; errors: FileValidationError[] }> = [];

  if (rules.maxFiles && files.length > rules.maxFiles) {
    return {
      valid: false,
      acceptedFiles: [],
      rejectedFiles: files.map((file) => ({
        file,
        errors: [{
          code: 'TOO_MANY_FILES',
          message: `Разрешено загрузить не более ${rules.maxFiles} файлов`,
          file,
        }],
      })),
    };
  }

  for (const file of files) {
    const errors: FileValidationError[] = [];

    // Проверка максимального размера
    if (rules.maxSizeBytes && file.size > rules.maxSizeBytes) {
      errors.push({
        code: 'FILE_TOO_LARGE',
        message: `Размер файла (${(file.size / 1024 / 1024).toFixed(2)} МБ) превышает лимит ${(rules.maxSizeBytes / 1024 / 1024).toFixed(2)} МБ`,
        file,
      });
    }

    // Проверка минимального размера
    if (rules.minSizeBytes && file.size < rules.minSizeBytes) {
      errors.push({
        code: 'FILE_TOO_SMALL',
        message: 'Файл пуст или его размер меньше допустимого минимума',
        file,
      });
    }

    // Проверка соответствия типу и расширению
    if (rules.accept && !isMimeMatched(file, rules.accept)) {
      errors.push({
        code: 'INVALID_TYPE',
        message: `Тип файла не поддерживается. Разрешены форматы: ${rules.accept.join(', ')}`,
        file,
      });
    }

    if (errors.length > 0) {
      rejectedFiles.push({ file, errors });
    } else {
      acceptedFiles.push(file);
    }
  }

  if (rejectedFiles.length > 0) {
    return {
      valid: false,
      acceptedFiles,
      rejectedFiles,
    };
  }

  return {
    valid: true,
    files: acceptedFiles,
  };
}

Реализация хука useFileDropzone в React

Интегрируем логику в переиспользуемый хук для React с поддержкой типизации событий Drag & Drop и обычного инпута.

import React, { useState, useCallback, useRef } from 'react';

interface UseFileDropzoneOptions extends FileValidationRules {
  onDropAccepted?: (files: File[]) => void;
  onDropRejected?: (rejections: ValidationFailure['rejectedFiles']) => void;
}

export function useFileDropzone(options: UseFileDropzoneOptions = {}) {
  const [isDragActive, setIsDragActive] = useState(false);
  const dragCounter = useRef(0);
  const inputRef = useRef<HTMLInputElement | null>(null);

  const handleFiles = useCallback(async (fileList: FileList | null) => {
    if (!fileList || fileList.length === 0) return;

    const rawFiles = Array.from(fileList);
    const result = await validateFiles(rawFiles, options);

    if (result.valid) {
      options.onDropAccepted?.(result.files);
    } else {
      if (result.acceptedFiles.length > 0) {
        options.onDropAccepted?.(result.acceptedFiles);
      }
      options.onDropRejected?.(result.rejectedFiles);
    }
  }, [options]);

  const onDragEnter = useCallback((e: React.DragEvent<HTMLElement>) => {
    e.preventDefault();
    e.stopPropagation();
    dragCounter.current += 1;

    if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) {
      setIsDragActive(true);
    }
  }, []);

  const onDragLeave = useCallback((e: React.DragEvent<HTMLElement>) => {
    e.preventDefault();
    e.stopPropagation();
    dragCounter.current -= 1;

    if (dragCounter.current === 0) {
      setIsDragActive(false);
    }
  }, []);

  const onDragOver = useCallback((e: React.DragEvent<HTMLElement>) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.dataTransfer) {
      e.dataTransfer.dropEffect = 'copy';
    }
  }, []);

  const onDrop = useCallback((e: React.DragEvent<HTMLElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragActive(false);
    dragCounter.current = 0;

    if (e.dataTransfer?.files) {
      void handleFiles(e.dataTransfer.files);
    }
  }, [handleFiles]);

  const onInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    void handleFiles(e.target.files);
    // Очищаем значение, чтобы повторный выбор того же файла корректно вызывал onChange
    e.target.value = '';
  }, [handleFiles]);

  const openFileDialog = useCallback(() => {
    inputRef.current?.click();
  }, []);

  return {
    isDragActive,
    getRootProps: () => ({
      onDragEnter,
      onDragLeave,
      onDragOver,
      onDrop,
      onClick: openFileDialog,
      role: 'presentation',
    }),
    getInputProps: () => ({
      ref: inputRef,
      type: 'file' as const,
      style: { display: 'none' },
      accept: options.accept?.join(','),
      onChange: onInputChange,
      multiple: (options.maxFiles ?? 1) > 1,
    }),
  };
}

Архитектурные границы и безопасность

Ключевое правило проектирования файловых загрузок: клиент отвечает за UX, сервер отвечает за безопасность.

[ КЛИЕНТ (Браузер) ]
  └── UX-валидация:
      ├── Проверка accept и расширения (мгновенная обратная связь)
      ├── Проверка размера (экономия трафика)
      └── Проверка сигнатуры Magic Bytes (отсечение случайных ошибок)
  └── Результат: отзывчивый интерфейс без лишних тяжелых запросов

      │ (Сетевой запрос / multipart/form-data)
      ▼

[ СЕРВЕР (Node.js / Go / S3 Lambda) ]
  └── Security-валидация:
      ├── Строгая проверка бинарных сигнатур
      ├── Полная декомпрессия и верификация структур (для архивов и медиа)
      ├── Санитайзинг метаданных (очистка EXIF)
      ├── Антивирусное сканирование
      └── Перекодирование / перепаковка медиа перед сохранением в хранилище
  └── Результат: надежная защита инфраструктуры

Клиентский JavaScript исполняется в недоверенной среде. Любые браузерные проверки можно обойти прямой отправкой данных через curl или консоль разработчика. Серверный эндпоинт загрузки обязан независимо выполнять полную бинарную валидацию каждого полученного потока данных.


Часто задаваемые вопросы (FAQ)

Почему File.type иногда возвращает пустую строку ""?

Браузер не читает бинарное содержимое файла в момент создания объекта File. Определение MIME-типа делегируется операционной системе, сопоставляющей расширение с системной базой форматов. Если у файла нет расширения или тип не зарегистрирован в ОС, возвращается пустая строка.

Можно ли узнать размер и имена файлов в событии dragover?

Нет. В соответствии со спецификацией HTML Drag and Drop, в событиях dragenter и dragover хранилище данных находится в защищенном режиме. Доступен только список форматов dataTransfer.types. Свойства dataTransfer.files и items становятся доступны исключительно после наступления события drop.

Зачем валидировать Magic Bytes на клиенте, если есть сервер?

Проверка сигнатуры первых байт на клиенте позволяет моментально предупредить пользователя, если файл поврежден или переименован с некорректным расширением. Это экономит время пользователя, исходящий трафик и снижает паразитную нагрузку на бэкенд.

В чем разница между accept="image/*" и явным перечислением image/png, image/jpeg?

Маска image/* разрешает выбор любых форматов изображений, зарегистрированных в ОС пользователя, включая .svg, .webp, .heic, .ico и .tiff. Если бизнес-логика приложения не поддерживает векторную графику или специфические форматы, их следует ограничивать явным списком MIME-типов или расширений.

Зачем сбрасывать input.value = '' после каждого выбора?

Если пользователь выбрал файл, получил ошибку валидации, исправил файл на диске под тем же именем и выбрал его повторно, нативное событие change не сработает, так как значение свойства value у элемента <input> не изменилось. Сброс значения в пустую строку гарантирует срабатывание события при каждом новом выборе.


Заключение

Надежная клиентская валидация файлов строится на трех инженерных принципах:

  1. Корректная обработка жизненного цикла Drag & Drop API с учетом ограничений защищенного режима DataTransfer.
  2. Многоуровневая проверка формата с обязательным fallback-анализом расширений, компенсирующим нестабильность свойства File.type.
  3. Разграничение ответственности: клиентский код берет на себя отзывчивость интерфейса и экономию ресурсов, а окончательный контроль целостности и безопасности всегда остается за бэкендом.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также