aboutsummaryrefslogtreecommitdiff
path: root/api/debug/report.php
blob: cefcae6abd42ad94211f36de5ee9a4e5f11d120d (plain)
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
100
101
102
103
104
105
106
107
108
109
110
111
112
<?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) {
        // 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,
    '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);