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; } $firstByte = ord($value[0]); if ($firstByte === 0) { return $this->ensureUtf8(substr($value, 1)); } if ($firstByte === 1) { $payload = substr($value, 1); $decoded = base64_decode($payload, true); return $this->ensureUtf8($decoded !== false ? $decoded : $payload); } $decoded = $value; $hadDecode = false; for ($i = 0; $i < 2; $i++) { $tmp = base64_decode($decoded, true); if ($tmp === false) { break; } $hadDecode = true; $decoded = $this->stripNulls($tmp); if ($decoded === null || $decoded === '') { break; } if (!$this->isBase64($decoded)) { break; } } if ($hadDecode && is_string($decoded)) { return $this->ensureUtf8($decoded); } if ($this->isBase64($value)) { $single = base64_decode($value, true); if ($single !== false) { return $this->ensureUtf8($single); } } return $this->ensureUtf8($value); } /** * Подготавливает строку к сохранению в 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); $raw = $this->prepareRawPayload($value); $candidates = [ base64_encode(base64_encode($raw)), "\x01" . base64_encode($raw), "\x00" . $raw, ]; $payload = null; foreach ($candidates as $candidate) { if (!is_int($maxLen) || strlen($candidate) <= $maxLen) { $payload = $candidate; break; } } if ($payload === null) { if (!is_int($maxLen) || $maxLen <= 0) { return DB::raw('0x'); } $allowedRaw = max(0, $maxLen - 1); $raw = substr($raw, 0, $allowedRaw); $payload = "\x00" . $raw; } return DB::raw('0x' . bin2hex($payload)); } private function prepareRawPayload(string $value): string { if ($value === '') { return ''; } if ($this->isBase64($value)) { $first = base64_decode($value, true); if (is_string($first)) { $first = $this->stripNulls($first) ?? ''; if ($first !== '' && $this->isBase64($first)) { $second = base64_decode($first, true); if (is_string($second)) { $second = $this->stripNulls($second) ?? ''; return $second; } } return $first; } } if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'CP1251//IGNORE', $value); if (is_string($converted)) { return $converted; } } return $value; } /** * Удаляет завершающие нулевые байты (часто встречаются в 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; } }