diff options
Diffstat (limited to 'api/debug')
| -rw-r--r-- | api/debug/dashboard.php | 2 | ||||
| -rw-r--r-- | api/debug/ingest.php | 10 | ||||
| -rw-r--r-- | api/debug/report.php | 99 |
3 files changed, 108 insertions, 3 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php index 7a46661..c235ec0 100644 --- a/api/debug/dashboard.php +++ b/api/debug/dashboard.php @@ -151,7 +151,7 @@ if ($lastSync): ?> <td><?= $i++ ?></td> <td class="max-w-300"><a href="error.php?hash=<?= urlencode($e['hash'] ?? '') ?>" style="color:var(--text);text-decoration:none;" title="View details"><?= htmlspecialchars(substr($e['message'] ?? '', 0, 80)) ?></a></td> <td><span class="tag"><?= htmlspecialchars($e['type'] ?? '?') ?></span></td> - <td><?= $e['count'] ?></td> + <td><?= (int)($e['count'] ?? 0) ?></td> </tr> <?php endforeach; ?> <?php if (empty($topErrors)): ?> diff --git a/api/debug/ingest.php b/api/debug/ingest.php index 363dd09..b513593 100644 --- a/api/debug/ingest.php +++ b/api/debug/ingest.php @@ -17,6 +17,12 @@ $errors = $payload['errors']; $session = $payload['session'] ?? []; $clientVersion = $payload['version'] ?? 'unknown'; +// Client-supplied counts are untrusted: clamp so a bad client cannot store 38,495× rows. +function clamp_count($v) { + $n = (int)$v; + return $n < 1 ? 1 : ($n > 1000 ? 1000 : $n); +} + // Validate batch size if (count($errors) > MAX_ERRORS_PER_BATCH) { $errors = array_slice($errors, 0, MAX_ERRORS_PER_BATCH); @@ -65,7 +71,7 @@ foreach ($errors as $e) { if (isset($existingHashes[$hash])) { // Update existing entry $idx = $existingHashes[$hash]; - $allErrors[$idx]['count'] += $e['count'] ?? 1; + $allErrors[$idx]['count'] += clamp_count($e['count'] ?? 1); $allErrors[$idx]['last_seen'] = $e['lastSeen'] ?? $now; $allErrors[$idx]['ingested_at'] = $now; if (!empty($e['stack'])) { @@ -85,7 +91,7 @@ foreach ($errors as $e) { 'module' => $e['module'] ?? null, 'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null, 'session_id' => $sessionId, - 'count' => $e['count'] ?? 1, + 'count' => clamp_count($e['count'] ?? 1), 'first_seen' => $e['firstSeen'] ?? $now, 'last_seen' => $e['lastSeen'] ?? $now, 'ingested_at' => $now, diff --git a/api/debug/report.php b/api/debug/report.php new file mode 100644 index 0000000..a4cf9f1 --- /dev/null +++ b/api/debug/report.php @@ -0,0 +1,99 @@ +<?php +// api/debug/report.php — structured bug-report intake (Part A) +// POST { title, body?, diagnostics{...} } -> { 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'] : []; + +// 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); + +// Dedupe: same title + error set + version within 24h collapses onto the existing report. +$fingerprint = sha1($title . '|' . json_encode($diag['errors'] ?? []) . '|' . (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) { + 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, + '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); + +json_response([ + 'success' => true, + 'id' => $id, + 'duplicate' => false, + 'url' => '/spaces/newos4/downloads/bugs/BUG-REPORT.html', +], 201); |