Integrate Coolify API client and module for managing Coolify services, enhancing automation and deployment processes.
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class error_report_service
|
||||
{
|
||||
private const ANSWER_MAX_LENGTH = 4000;
|
||||
private const NOTE_MAX_LENGTH = 2000;
|
||||
private const SCREENSHOT_MAX_BYTES = 6_000_000;
|
||||
private const JSON_MAX_LENGTH = 200_000;
|
||||
private const STATUS_OPEN = 'open';
|
||||
private const STATUS_RESOLVED = 'resolved';
|
||||
|
||||
private bool $schemaEnsured = false;
|
||||
private error_report_store $store;
|
||||
|
||||
public function __construct(?error_report_store $store = null)
|
||||
{
|
||||
$this->store = $store ?? new error_report_store();
|
||||
}
|
||||
|
||||
public static function redactPayload(mixed $value, int $depth = 0): mixed
|
||||
{
|
||||
if ($depth > 8) {
|
||||
return '[depth-limit]';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$redacted = [];
|
||||
$index = 0;
|
||||
foreach ($value as $key => $item) {
|
||||
$index++;
|
||||
if ($index > 80) {
|
||||
$redacted['[truncated]'] = 'More than 80 keys omitted.';
|
||||
break;
|
||||
}
|
||||
|
||||
$keyString = (string)$key;
|
||||
if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) {
|
||||
$redacted[$key] = '[redacted]';
|
||||
continue;
|
||||
}
|
||||
|
||||
$redacted[$key] = self::redactPayload($item, $depth + 1);
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return self::redactPayload((array)$value, $depth + 1);
|
||||
}
|
||||
|
||||
if (is_string($value) && strlen($value) > 4000) {
|
||||
return substr($value, 0, 4000) . "\n... [truncated]";
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function decodeScreenshotDataUri(string $dataUri): array
|
||||
{
|
||||
if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) {
|
||||
throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.');
|
||||
}
|
||||
|
||||
$contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true);
|
||||
if ($contents === false || $contents === '') {
|
||||
throw new RuntimeException('Screenshot could not be decoded.');
|
||||
}
|
||||
|
||||
if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) {
|
||||
throw new RuntimeException('Screenshot is too large.');
|
||||
}
|
||||
|
||||
return [
|
||||
'mime_type' => $matches[1],
|
||||
'contents' => $contents,
|
||||
'size_bytes' => strlen($contents),
|
||||
];
|
||||
}
|
||||
|
||||
public function createFromCurrentPrincipal(array $payload): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$principal = $this->resolvePrincipal();
|
||||
$answers = $this->validatedAnswers($payload);
|
||||
|
||||
if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) {
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
status,
|
||||
reporter_type,
|
||||
reporter_user_id,
|
||||
reporter_subuser_id,
|
||||
reporter_customer_number,
|
||||
reporter_customer_number_context,
|
||||
reporter_name,
|
||||
reporter_email,
|
||||
route_path,
|
||||
page_url,
|
||||
release_trace_id,
|
||||
frontend_version,
|
||||
api_version,
|
||||
screenshot_object_key,
|
||||
screenshot_mime_type,
|
||||
screenshot_size_bytes,
|
||||
before_error,
|
||||
expected,
|
||||
actual,
|
||||
request_error_count,
|
||||
vue_error_count,
|
||||
request_errors_json,
|
||||
vue_errors_json,
|
||||
runtime_context_json,
|
||||
data_collection_accepted,
|
||||
data_collection_accepted_at,
|
||||
data_collection_policy_version
|
||||
) VALUES (
|
||||
'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ?
|
||||
)",
|
||||
'siiiisssssssssisssiissss',
|
||||
[
|
||||
$principal['type'],
|
||||
$principal['user_id'],
|
||||
$principal['subuser_id'],
|
||||
$principal['customer_number'],
|
||||
$principal['customer_number_context'],
|
||||
$principal['name'],
|
||||
$principal['email'],
|
||||
$runtimeContext['route_path'],
|
||||
$runtimeContext['page_url'],
|
||||
$runtimeContext['release_trace_id'],
|
||||
$runtimeContext['frontend_version'],
|
||||
$runtimeContext['api_version'],
|
||||
$storedScreenshot['key'],
|
||||
$storedScreenshot['mime_type'],
|
||||
(int)$storedScreenshot['size_bytes'],
|
||||
$answers['before_error'],
|
||||
$answers['expected'],
|
||||
$answers['actual'],
|
||||
count($requestErrors),
|
||||
count($vueErrors),
|
||||
$this->jsonEncodeLimited(self::redactPayload($requestErrors)),
|
||||
$this->jsonEncodeLimited(self::redactPayload($vueErrors)),
|
||||
$this->jsonEncodeLimited(self::redactPayload($runtimeContext)),
|
||||
$runtimeContext['data_collection_policy_version'],
|
||||
]
|
||||
);
|
||||
|
||||
return $this->get($this->insertId());
|
||||
}
|
||||
|
||||
public function list(array $filters = []): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
|
||||
$where = ['1 = 1'];
|
||||
$types = '';
|
||||
$params = [];
|
||||
$status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN);
|
||||
if ($status !== 'all') {
|
||||
$where[] = 'status = ?';
|
||||
$types .= 's';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$search = trim((string)($filters['q'] ?? $filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)';
|
||||
$types .= 'ssssss';
|
||||
$like = '%' . $search . '%';
|
||||
array_push($params, $like, $like, $like, $like, $like, $like);
|
||||
}
|
||||
|
||||
$limit = min(200, max(1, (int)($filters['limit'] ?? 50)));
|
||||
$offset = max(0, (int)($filters['offset'] ?? 0));
|
||||
$types .= 'ii';
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$items = $this->selectRows(
|
||||
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
|
||||
reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email,
|
||||
route_path, page_url, release_trace_id, frontend_version, api_version,
|
||||
screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual,
|
||||
request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at
|
||||
FROM error_reports
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
$types,
|
||||
$params
|
||||
);
|
||||
|
||||
return [
|
||||
'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items),
|
||||
'counts' => $this->counts(),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
public function get(int $id): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]);
|
||||
if ($row === null) {
|
||||
throw new RuntimeException('Error report not found.');
|
||||
}
|
||||
|
||||
return $this->publicReport($row, true);
|
||||
}
|
||||
|
||||
public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$status = self::normalizeStatus($status);
|
||||
$note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null;
|
||||
|
||||
if ($status === self::STATUS_RESOLVED) {
|
||||
$this->execute(
|
||||
'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?',
|
||||
'sisi',
|
||||
[$status, $actorUserId, $note, $id]
|
||||
);
|
||||
} else {
|
||||
$this->execute(
|
||||
'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?',
|
||||
'ssi',
|
||||
[$status, $note, $id]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->get($id);
|
||||
}
|
||||
|
||||
public static function normalizeStatus(string $status): string
|
||||
{
|
||||
$status = strtolower(trim($status));
|
||||
if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) {
|
||||
throw new RuntimeException('Invalid error report status.');
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function validatedAnswers(array $payload): array
|
||||
{
|
||||
return [
|
||||
'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']),
|
||||
'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']),
|
||||
'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']),
|
||||
];
|
||||
}
|
||||
|
||||
private function requiredAnswer(array $payload, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true);
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Missing required answer.');
|
||||
}
|
||||
|
||||
private function trimmedString(string $value, int $maxLength, bool $required): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($required && $value === '') {
|
||||
throw new RuntimeException('Required text fields must not be empty.');
|
||||
}
|
||||
|
||||
if (strlen($value) > $maxLength) {
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function acceptedDataCollection(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512),
|
||||
'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024),
|
||||
'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64),
|
||||
'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128),
|
||||
'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128),
|
||||
'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null,
|
||||
'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024),
|
||||
'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64),
|
||||
'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1',
|
||||
];
|
||||
}
|
||||
|
||||
private function releaseRequestContext(string $key): ?string
|
||||
{
|
||||
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : [];
|
||||
return isset($context[$key]) ? (string)$context[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value, int $maxLength): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string)$value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
|
||||
private function boundedArray(mixed $value, int $limit): array
|
||||
{
|
||||
return is_array($value) ? array_slice(array_values($value), 0, $limit) : [];
|
||||
}
|
||||
|
||||
private function statusFilter(mixed $status): string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if ($status === '' || $status === self::STATUS_OPEN) {
|
||||
return self::STATUS_OPEN;
|
||||
}
|
||||
if ($status === self::STATUS_RESOLVED || $status === 'all') {
|
||||
return $status;
|
||||
}
|
||||
return self::STATUS_OPEN;
|
||||
}
|
||||
|
||||
private function counts(): array
|
||||
{
|
||||
$rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status');
|
||||
$counts = [
|
||||
self::STATUS_OPEN => 0,
|
||||
self::STATUS_RESOLVED => 0,
|
||||
'all' => 0,
|
||||
];
|
||||
foreach ($rows as $row) {
|
||||
$status = (string)($row['status'] ?? '');
|
||||
$count = (int)($row['count'] ?? 0);
|
||||
if (isset($counts[$status])) {
|
||||
$counts[$status] = $count;
|
||||
}
|
||||
$counts['all'] += $count;
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function resolvePrincipal(): array
|
||||
{
|
||||
$auth = new authentication();
|
||||
|
||||
try {
|
||||
$subuser = $auth->get_subuser();
|
||||
if ($subuser !== false) {
|
||||
return [
|
||||
'type' => 'subuser',
|
||||
'user_id' => null,
|
||||
'subuser_id' => (int)$subuser->id,
|
||||
'customer_number' => null,
|
||||
'customer_number_context' => $this->headerInt('X-Customer-Number'),
|
||||
'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'),
|
||||
'email' => $this->safeObjectValue($subuser, 'email'),
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $auth->get_user();
|
||||
if ($user !== false) {
|
||||
return [
|
||||
'type' => 'user',
|
||||
'user_id' => (int)$user->id,
|
||||
'subuser_id' => null,
|
||||
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
|
||||
'customer_number_context' => null,
|
||||
'name' => $this->safeObjectValue($user, 'display_name'),
|
||||
'email' => $this->safeObjectValue($user, 'email'),
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
throw new RuntimeException('Authentication failed. Invalid or missing token.');
|
||||
}
|
||||
|
||||
private function headerInt(string $name): ?int
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||
foreach ($headers as $key => $value) {
|
||||
if (strcasecmp((string)$key, $name) === 0) {
|
||||
$int = (int)$value;
|
||||
return $int > 0 ? $int : null;
|
||||
}
|
||||
}
|
||||
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
$int = (int)($_SERVER[$serverKey] ?? 0);
|
||||
return $int > 0 ? $int : null;
|
||||
}
|
||||
|
||||
private function safeObjectValue(object $object, string $property): ?string
|
||||
{
|
||||
try {
|
||||
if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) {
|
||||
return null;
|
||||
}
|
||||
$value = $object->{$property}->value();
|
||||
return $value === null ? null : substr((string)$value, 0, 255);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
'reporter' => [
|
||||
'type' => $row['reporter_type'] ?? null,
|
||||
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
|
||||
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
|
||||
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
|
||||
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
|
||||
'name' => $row['reporter_name'] ?? null,
|
||||
'email' => $row['reporter_email'] ?? null,
|
||||
],
|
||||
'route_path' => $row['route_path'] ?? null,
|
||||
'page_url' => $row['page_url'] ?? null,
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
'actual' => $row['actual'] ?? '',
|
||||
],
|
||||
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
|
||||
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
|
||||
'resolved_at' => $row['resolved_at'] ?? null,
|
||||
'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
$report['data_collection'] = [
|
||||
'accepted' => (bool)($row['data_collection_accepted'] ?? false),
|
||||
'accepted_at' => $row['data_collection_accepted_at'] ?? null,
|
||||
'policy_version' => $row['data_collection_policy_version'] ?? null,
|
||||
];
|
||||
$report['resolution_note'] = $row['resolution_note'] ?? null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
private function ensureSchema(): void
|
||||
{
|
||||
if ($this->schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
error_report_schema_bootstrap::ensureTables();
|
||||
$this->schemaEnsured = true;
|
||||
}
|
||||
|
||||
private function selectOne(string $sql, string $types = '', array $params = []): ?array
|
||||
{
|
||||
$rows = $this->selectRows($sql, $types, $params);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function selectRows(string $sql, string $types = '', array $params = []): array
|
||||
{
|
||||
global $db;
|
||||
if ($types === '') {
|
||||
$result = $db->query($sql);
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new RuntimeException('Could not prepare error report query.');
|
||||
}
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
private function execute(string $sql, string $types = '', array $params = []): void
|
||||
{
|
||||
global $db;
|
||||
if ($types === '') {
|
||||
$db->query($sql);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new RuntimeException('Could not prepare error report statement.');
|
||||
}
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
private function insertId(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->insert_id();
|
||||
}
|
||||
|
||||
private function jsonEncodeLimited(mixed $value): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Could not encode error report JSON payload.');
|
||||
}
|
||||
if (strlen($json) <= self::JSON_MAX_LENGTH) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
$truncated = [
|
||||
'[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.',
|
||||
'preview' => substr($json, 0, self::JSON_MAX_LENGTH),
|
||||
];
|
||||
$encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $encoded === false ? '{}' : $encoded;
|
||||
}
|
||||
|
||||
private function jsonDecode(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user