int|null] */ private static $maxLenCache = []; /** * Декодирует значение из двойного base64 и приводит к UTF-8. */ public function get($model, string $key, $value, array $attributes) { $value = $this->stripNulls($value); if ($value === null || $value === '') { return $value; } $decoded = $value; for ($i = 0; $i < 2; $i++) { $tmp = base64_decode($decoded, true); if ($tmp === false) { break; } $decoded = $this->stripNulls($tmp); } return $decoded === null ? null : $this->ensureUtf8($decoded); } /** * Подготавливает строку к сохранению в VARBINARY: CP1251 → двойной base64 → HEX 0x... */ public function set($model, string $key, $value, array $attributes) { $value = $this->stripNulls($value); if ($value === null) { return $value; // сохраняем NULL как есть } if ($value === '') { // Пустая бинарная строка для VARBINARY в MSSQL return DB::raw('0x'); } // Определяем максимально допустимую длину VARBINARY для данной колонки $maxLen = $this->getVarbinaryMaxLength($model, $key); // Готовим "сырой" CP1251 (или то, что пришло в base64) и итоговый двойной base64 $raw = null; if ($this->isBase64($value)) { $inner = base64_decode($value, true); if ($inner !== false && $this->isBase64($inner)) { // Значение уже двойной base64: пробуем восстановить сырой текст $raw = base64_decode($inner, true); $encoded = $value; // пока используем исходный двойной base64 } else { // Только один слой base64 → сырой текст после одного декодирования $raw = $inner !== false ? $inner : null; $encoded = base64_encode($value); } } if ($raw === null) { // Готовим сырой CP1251 из UTF-8 $prepared = function_exists('iconv') ? @iconv('UTF-8', 'CP1251//IGNORE', $value) : $value; if ($prepared === false) { $prepared = $value; } $raw = $prepared; $encoded = base64_encode(base64_encode($prepared)); } // Если известна максимальная длина и результат не помещается, // пересчитываем кодирование для усечения по сырой длине так, чтобы двойной base64 гарантированно влез if (is_int($maxLen) && strlen($encoded) > $maxLen) { $maxRaw = $this->maxRawLenForDoubleBase64($maxLen); $raw = substr($raw, 0, max(0, $maxRaw)); $encoded = base64_encode(base64_encode($raw)); } // MSSQL VARBINARY требует явного бинарного литерала return DB::raw('0x' . bin2hex($encoded)); } /** * Удаляет завершающие нулевые байты (часто встречаются в char(n)). */ private function stripNulls(?string $value): ?string { return $value === null ? null : rtrim($value, "\0"); } /** * Лояльная проверка, что строка похожа на base64. */ private function isBase64(string $value): bool { if ($value === '') { return false; } if (preg_match('/^[A-Za-z0-9+\/\r\n]+=*$/', $value) !== 1) { return false; } return (strlen($value) % 4 === 0); } /** * Гарантирует корректный UTF-8, пытаясь конвертацию из CP1251/ISO-8859-1 * и удаляя управляющие символы. */ private function ensureUtf8(string $value): string { if ($value === '') { return ''; } if (mb_check_encoding($value, 'UTF-8')) { return $this->removeControlChars($value); } $candidates = []; if (function_exists('iconv')) { $candidates[] = fn () => @iconv('CP1251', 'UTF-8//IGNORE', $value); } if (function_exists('mb_convert_encoding')) { $candidates[] = fn () => @mb_convert_encoding($value, 'UTF-8', 'CP1251'); $candidates[] = fn () => @mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1'); } $candidates[] = fn () => utf8_encode($value); foreach ($candidates as $candidate) { $converted = $candidate(); if (is_string($converted) && mb_check_encoding($converted, 'UTF-8')) { return $this->removeControlChars($converted); } } if (function_exists('iconv')) { $sanitized = @iconv('UTF-8', 'UTF-8//IGNORE', $value); if (is_string($sanitized) && mb_check_encoding($sanitized, 'UTF-8')) { return $this->removeControlChars($sanitized); } } return ''; } /** * Удаляет управляющие ASCII-символы (0x00–0x1F, 0x7F). */ private function removeControlChars(string $value): string { $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value); return is_string($clean) ? $clean : ''; } /** * Возвращает максимально допустимую длину VARBINARY для указанной колонки. * Для varbinary(max) возвращает null (без ограничения). */ private function getVarbinaryMaxLength($model, string $key): ?int { try { $connection = $model->getConnectionName(); $table = $model->getTable(); $cacheKey = $connection . '.' . $table . '.' . $key; if (array_key_exists($cacheKey, self::$maxLenCache)) { return self::$maxLenCache[$cacheKey]; } $conn = DB::connection($connection); // Запрашиваем max_length из системных таблиц $sql = "SELECT c.max_length, t.name AS type_name FROM sys.columns c JOIN sys.types t ON c.user_type_id = t.user_type_id JOIN sys.tables tb ON c.object_id = tb.object_id WHERE tb.name = ? AND c.name = ?"; $rows = $conn->select($sql, [$table, $key]); if (!empty($rows)) { $row = (array) $rows[0]; $type = isset($row['type_name']) ? $row['type_name'] : ($row['type_name'] ?? null); $max = isset($row['max_length']) ? (int) $row['max_length'] : null; // Для varbinary(max) max_length = -1 if ($max === -1 && in_array(strtolower((string) $type), ['varbinary', 'binary'], true)) { self::$maxLenCache[$cacheKey] = null; return null; } self::$maxLenCache[$cacheKey] = $max; return $max; } } catch (\Throwable $e) { // Игнорируем ошибки определения схемы, работаем без ограничения } return null; } /** * Вычисляет максимально допустимую длину "сырого" текста (в байтах), * чтобы длина двойного base64 не превышала $maxLen. */ private function maxRawLenForDoubleBase64(int $maxLen): int { // Итеративный безопасный расчёт for ($n = $maxLen; $n >= 0; $n--) { $b1 = 4 * (int) ceil($n / 3); $b2 = 4 * (int) ceil($b1 / 3); if ($b2 <= $maxLen) { return $n; } } return 0; } }