{ success, id, duplicate, url } require_once __DIR__ . '/config.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { error_response('Method not allowed', 405); } define('REPORTS_DIR', __DIR__ . '/../../data/reports'); define('RATE_FILE', DEBUG_DATA_DIR . '/report_rate.json'); define('MAX_REPORT_KB', 512); define('RATE_MAX', 5); // reports per IP … define('RATE_WINDOW', 600); // … per 10 minutes $raw = file_get_contents('php://input'); if (!$raw) error_response('Empty request body'); if (strlen($raw) > MAX_REPORT_KB * 1024) { error_response('Payload too large (max ' . MAX_REPORT_KB . 'KB)', 413); } $payload = json_decode($raw, true); if (!is_array($payload)) error_response('Invalid JSON'); $title = trim((string)($payload['title'] ?? '')); if ($title === '') error_response('title is required'); if (mb_strlen($title) > 160) $title = mb_substr($title, 0, 160); $body = (string)($payload['body'] ?? ''); if (mb_strlen($body) > 4000) $body = mb_substr($body, 0, 4000); $diag = (isset($payload['diagnostics']) && is_array($payload['diagnostics'])) ? $payload['diagnostics'] : []; // Bound every stack: 20 errors x 10 frames of long URLs can otherwise approach the 512 KB ceiling // and reject a legitimate report. if (!empty($diag['errors']) && is_array($diag['errors'])) { foreach ($diag['errors'] as $k => $de) { if (!is_array($de)) { unset($diag['errors'][$k]); continue; } if (isset($de['stack'])) { $diag['errors'][$k]['stack'] = mb_substr((string)$de['stack'], 0, 2000); } } $diag['errors'] = array_values($diag['errors']); } // Maintenance thread: lets the in-app agent and the Hermes maintenance job hold a conversation. // `thread` is a client-generated id (thr-xxxxxxxx); `kind` distinguishes agent feedback from // operator bug reports so triage can weight them differently. $thread = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($payload['thread'] ?? '')); if (mb_strlen($thread) > 40) $thread = substr($thread, 0, 40); $kind = (($payload['kind'] ?? '') === 'maintenance') ? 'maintenance' : 'user'; // Storage guard: data/ is not writable by php-fpm, so never pretend we wrote a report. if (!is_dir(REPORTS_DIR)) @mkdir(REPORTS_DIR, 0775, true); if (!is_dir(REPORTS_DIR) || !is_writable(REPORTS_DIR)) { error_response('Report storage unavailable', 503); } // Per-IP rate limit $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $now = time(); $rate = load_json(RATE_FILE); $hits = array_values(array_filter($rate[$ip] ?? [], function ($t) use ($now) { return $t > $now - RATE_WINDOW; })); if (count($hits) >= RATE_MAX) { header('Retry-After: ' . RATE_WINDOW); error_response('Rate limit exceeded (max ' . RATE_MAX . ' reports per 10 minutes)', 429); } $hits[] = $now; $rate[$ip] = $hits; foreach ($rate as $k => $v) { $v = array_values(array_filter($v, function ($t) use ($now) { return $t > $now - 86400; })); if ($v) $rate[$k] = $v; else unset($rate[$k]); } save_json(RATE_FILE, $rate); // ── Network-class filter (2026-10-26) ──────────────────────────────────────────────────────── // The debug supervisor files an auto report for *any* failed fetch, so a flaky phone connection // or a retired endpoint becomes a review card. One cluster alone held 14 400 occurrences and // produced 3 cards. A `Fetch failed:` line is a retry, not a defect: count it and stop. // Only auto reports whose every error is network-class are filtered — a stack trace, TypeError // or user-filed report is never dropped here. define('NET_FILE', DEBUG_DATA_DIR . '/network_failures.json'); define('NET_KEEP_SOURCES', 40); // bounded: keep the noisiest paths only $isNetworkMsg = function ($m) { return (bool)preg_match( '/fetch failed|failed to fetch|networkerror|network error|load failed' . '|the user aborted a request|signal is aborted|err_internet_disconnected|err_network_changed/i', (string)$m ); }; if (!empty($diag['auto']) && !empty($diag['errors']) && is_array($diag['errors'])) { $msgs = []; foreach ($diag['errors'] as $de) { if (is_array($de)) $msgs[] = (string)($de['message'] ?? ''); } $netCount = count(array_filter($msgs, $isNetworkMsg)); if ($msgs && $netCount === count($msgs)) { $day = gmdate('Y-m-d'); $net = load_json(NET_FILE); if (!is_array($net) || ($net['day'] ?? '') !== $day) { $net = ['day' => $day, 'total' => 0, 'filtered_reports' => 0, 'by_source' => []]; } $net['total'] = (int)($net['total'] ?? 0) + 1; $net['filtered_reports'] = (int)($net['filtered_reports'] ?? 0) + 1; $src = 'unknown'; foreach (array_merge($msgs, [$title]) as $candidate) { if (preg_match('~(/[A-Za-z0-9_\-./]+\.(?:php|json))~', (string)$candidate, $m)) { $src = $m[1]; break; } if (preg_match('~(https?://[^\s]+)~', (string)$candidate, $m)) { $src = preg_replace('~^https?://[^/]+~', '', $m[1]); break; } } $net['by_source'][$src] = (int)($net['by_source'][$src] ?? 0) + 1; if (count($net['by_source']) > NET_KEEP_SOURCES) { arsort($net['by_source']); $net['by_source'] = array_slice($net['by_source'], 0, NET_KEEP_SOURCES, true); } save_json(NET_FILE, $net); json_response([ 'success' => true, 'filtered' => 'network', 'source' => $src, 'day' => $day, 'count_today' => $net['total'], 'url' => null, ], 202); } } // Dedupe: same title + error set + version within 24h collapses onto the existing report. // Hash only the fields the pre-2026-10-23 payload had. Adding `stack` to json_encode($diag['errors']) // would change every fingerprint, so an identical error filed today would not dedupe against the // same error filed yesterday. $errKey = []; foreach (is_array($diag['errors'] ?? null) ? $diag['errors'] : [] as $de) { if (!is_array($de)) continue; $errKey[] = ['message' => (string)($de['message'] ?? ''), 'source' => (string)($de['source'] ?? ''), 'count' => (int)($de['count'] ?? 1)]; } $fingerprint = sha1($title . '|' . json_encode($errKey) . '|' . (string)($diag['version'] ?? '')); $existing = glob(REPORTS_DIR . '/RPT-*.json') ?: []; foreach (array_slice(array_reverse($existing), 0, 100) as $f) { $d = json_decode(@file_get_contents($f), true); if (!is_array($d)) continue; if (($d['fingerprint'] ?? '') === $fingerprint && strtotime($d['created_at'] ?? '') > $now - 86400) { // Don't discard user content on a duplicate: keep it as an appended note. $d['duplicate_count'] = (int)($d['duplicate_count'] ?? 0) + 1; if ($body !== '' && ($d['body'] ?? '') !== $body) { $d['body'] = trim((string)($d['body'] ?? '') . "\n---\n[duplicate " . gmdate('c') . "] " . $body); $json = json_encode($d, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json !== false) { @file_put_contents($f, $json); @chmod($f, 0664); } } json_response([ 'success' => true, 'id' => $d['id'], 'duplicate' => true, 'url' => '/spaces/newos4/downloads/bugs/BUG-REPORT.html', ]); } } $id = 'RPT-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8); $record = [ 'id' => $id, 'created_at' => gmdate('c'), 'fingerprint' => $fingerprint, 'title' => $title, 'body' => $body, 'diagnostics' => $diag, 'thread' => $thread, 'kind' => $kind, 'meta' => [ 'ip' => $ip, 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', ], ]; $file = REPORTS_DIR . '/' . $id . '.json'; $json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false || file_put_contents($file, $json) === false) { error_response('Failed to persist report', 500); } @chmod($file, 0664); // Regenerate the public page immediately so the URL we print is never stale. $generator = realpath(__DIR__ . '/../../scripts/gen-bug-report.py'); if ($generator) { @exec('/usr/bin/python3 ' . escapeshellarg($generator) . ' 2>&1', $genOut, $genRc); } json_response([ 'success' => true, 'id' => $id, 'duplicate' => false, 'url' => '/spaces/newos4/downloads/bugs/BUG-REPORT.html', ], 201);