aboutsummaryrefslogtreecommitdiff
path: root/api/debug/report.php
diff options
context:
space:
mode:
Diffstat (limited to 'api/debug/report.php')
-rw-r--r--api/debug/report.php99
1 files changed, 99 insertions, 0 deletions
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);