"Require all denied\nDeny from all\n", 'index.php' => " '' . '', ]; foreach ($protect as $name => $content) { if (!is_file($dir . '/' . $name)) { file_put_contents($dir . '/' . $name, $content); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- служебный файл защиты папки } } return $dir; } /** * Проверяет относительный путь из запроса 1С и возвращает его в нормальном виде. * Допускаются только латинские буквы, цифры, «_», «-», «.» и подпапки. */ public static function normalize(string $relative): string { $relative = str_replace('\\', '/', $relative); $relative = trim($relative, "/ \t\n\r"); if ($relative === '' || strlen($relative) > 512 || str_contains($relative, "\0")) { throw new ExchangeException('Недопустимое имя файла'); } $parts = explode('/', $relative); foreach ($parts as $part) { if ($part === '' || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-.]{1,128}$/', $part)) { throw new ExchangeException('Недопустимое имя файла: ' . esc_html(sanitize_text_field($relative))); } } $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if (!in_array($ext, self::EXTENSIONS, true)) { throw new ExchangeException('Недопустимый тип файла: ' . esc_html(sanitize_text_field($ext))); } if (in_array(strtolower($parts[0]), self::PROTECT_FILES, true)) { throw new ExchangeException('Недопустимое имя файла'); } return implode('/', $parts); } public static function path(string $relative): string { return self::dir() . '/' . self::normalize($relative); } /** * Лимит размера одного куска файла, сообщаемый 1С. */ public static function fileLimit(): int { $limit = Settings::int('file_limit_mb') * 1024 * 1024; $postMax = wp_convert_hr_to_bytes((string) ini_get('post_max_size')); if ($postMax > 0) { $limit = min($limit, (int) ($postMax * 0.9)); } return max(512 * 1024, $limit); } /** * Дописывает тело запроса к файлу (1С присылает большие файлы частями). */ public static function appendFromInput(string $relative): void { self::ensure(); $target = self::path($relative); $dir = dirname($target); if (!is_dir($dir) && !wp_mkdir_p($dir)) { throw new ExchangeException('Не удалось создать папку для файла'); } if (is_link($target)) { throw new ExchangeException('Недопустимый файл'); } // Потоковое копирование тела запроса в файл: WP_Filesystem не умеет php://input и дописывание частями, // а читать файл до 512 МБ в память нельзя. // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fclose $in = fopen('php://input', 'rb'); $out = fopen($target, 'ab'); if ($in === false || $out === false) { throw new ExchangeException('Не удалось записать файл'); } $max = self::fileLimit() + 1024 * 1024; $written = stream_copy_to_stream($in, $out, $max + 1); fclose($in); fclose($out); // phpcs:enable if ($written === false) { throw new ExchangeException('Ошибка записи файла'); } if ($written > $max) { wp_delete_file($target); throw new ExchangeException('Превышен размер части файла'); } Log::debug(sprintf('Принят файл %s (+%d байт)', $relative, $written)); } /** * Распаковывает все zip-архивы в корне папки обмена, проверяя каждое имя внутри архива. */ public static function extractArchives(): void { $dir = self::ensure(); foreach (glob($dir . '/*.zip') ?: [] as $zipFile) { self::extract($zipFile, $dir); wp_delete_file($zipFile); } } private static function extract(string $zipFile, string $dir): void { if (!class_exists(\ZipArchive::class)) { throw new ExchangeException('На сервере нет расширения PHP zip — отключите zip в настройках'); } $zip = new \ZipArchive(); if ($zip->open($zipFile, \ZipArchive::RDONLY) !== true) { throw new ExchangeException('Не удалось открыть архив ' . esc_html(basename($zipFile))); } try { if ($zip->numFiles > self::MAX_ZIP_ENTRIES) { throw new ExchangeException('Слишком много файлов в архиве'); } $total = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); if ($stat === false || str_ends_with($stat['name'], '/')) { continue; } // Пропускаем символические ссылки и всё, что не проходит проверку имени. if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && $opsys === \ZipArchive::OPSYS_UNIX && (($attr >> 16) & 0170000) === 0120000) { Log::warning('В архиве пропущена символическая ссылка: ' . $stat['name']); continue; } try { $relative = self::normalize($stat['name']); } catch (ExchangeException) { Log::warning('В архиве пропущен файл: ' . sanitize_text_field($stat['name'])); continue; } $total += (int) $stat['size']; if ($total > self::MAX_UNZIPPED_BYTES) { throw new ExchangeException('Архив слишком большой после распаковки'); } $target = $dir . '/' . $relative; if (!is_dir(dirname($target))) { wp_mkdir_p(dirname($target)); } $src = $zip->getStream($stat['name']); $dst = fopen($target, 'wb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- потоковая распаковка без чтения в память if ($src === false || $dst === false) { throw new ExchangeException('Не удалось распаковать ' . $relative); } // Читаем не больше заявленного размера — защита от поддельных заголовков. stream_copy_to_stream($src, $dst, (int) $stat['size']); fclose($src); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose($dst); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } } finally { $zip->close(); } Log::debug('Распакован архив ' . basename($zipFile)); } /** * Удаляет всё содержимое папки обмена, кроме защитных файлов. */ public static function clear(): void { $dir = self::dir(); if (!is_dir($dir)) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { /** @var \SplFileInfo $item */ $path = $item->getPathname(); if (dirname($path) === $dir && in_array($item->getFilename(), self::PROTECT_FILES, true)) { continue; } if ($item->isDir() && !$item->isLink()) { rmdir($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- папка уже пуста (обход снизу вверх) } else { wp_delete_file($path); } } } /** * @return array{files:int, bytes:int} */ public static function stats(): array { $dir = self::dir(); $files = 0; $bytes = 0; if (is_dir($dir)) { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $item) { if ($item->isFile() && !in_array($item->getFilename(), self::PROTECT_FILES, true)) { $files++; $bytes += $item->getSize(); } } } return ['files' => $files, 'bytes' => $bytes]; } public static function removeAll(): void { self::clear(); $dir = self::dir(); foreach (self::PROTECT_FILES as $f) { wp_delete_file($dir . '/' . $f); } if (is_dir($dir)) { rmdir($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- своя временная папка, уже пустая } } }