568 lines
22 KiB
PHP
568 lines
22 KiB
PHP
<?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 : [];
|
|
}
|
|
}
|