1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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);
|