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 : []; } }