check(Http::clientIp())) { Http::fail(429, 'rate_limited', 'Zu viele Reports. Bitte Sendefrequenz reduzieren.'); } $data = Http::body(); if ($data === []) { Http::fail(400, 'empty_body', 'Der Request-Body ist leer.'); } $environment = is_string($data['environment'] ?? null) ? $data['environment'] : null; // Session ist hier bewusst nicht erlaubt: dieser Endpunkt ist die // Maschinenschnittstelle. Das WebUI legt Items ueber index.php an. $context = ApiAuth::requireScope($db, 'bugtracker:report', $environment, false); $projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null; // Werkbank-Token duerfen in ein fremdes Projekt melden - ein Agent, der das SDK // in Projekt X integriert und dabei einen Fehler im Deploymentcenter findet, // muss ihn dort ablegen koennen. Laufzeit-Token duerfen das nicht: sie stecken // im ausgelieferten Client und sind extrahierbar. ApiAuth::enforceProject($context, $projectSlug, true); $homeProject = ApiAuth::homeProject($context); // Wohin die Meldung geht, wenn der Melder es nicht sagt. // // Hier stand die Bedingung "!isWerkbank": ein Laufzeit-Token wurde auf sein // Heimatprojekt festgenagelt, ein Werkbank-Token nicht - denn das darf ja // ausdruecklich in fremde Projekte melden. Uebersehen wurde der Fall, um den // es hier geht: Ein Werkbank-Token, das GAR KEIN Projekt nennt, wollte nicht // "irgendwohin", sondern nach Hause. Ohne Slug fiel es durch bis zur // Voreinstellung in BugRepo::reportItem() - und die hiess 'default'. // // 'default' ist kein Projekt. Es steht in keiner dc_projects-Zeile, also // blieb project_id NULL, und die Bindung desselben Tokens schlug beim // naechsten Zugriff zu: action=update antwortete project_forbidden. Der // Server legte Items in einem Projekt ab, das derselbe Melder nicht mehr // anfassen durfte - und die Antwort sah wie ein Erfolg aus. // // Die Regel jetzt in einem Satz: Ein Slug im Body gilt (soweit die Bindung // ihn erlaubt), sonst gilt das Heimatprojekt. Ein Ziel zu raten ist an dieser // Stelle keine Option mehr. $nennenswert = $projectSlug !== null && $projectSlug !== ''; if ($homeProject !== null && (!$nennenswert || !ApiAuth::isWorkbench($context))) { $data['project_slug'] = $homeProject; } // --- Ist das Ziel ueberhaupt ein Projekt? ----------------------------------- $repo = new BugRepo($db); $ziel = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null; if ($ziel === null || $ziel === '') { Http::fail( 400, 'missing_project', 'Es ist nicht erkennbar, wohin diese Meldung gehoert: weder wurde "project_slug" ' . 'mitgeschickt, noch ist dieses Token an ein Projekt gebunden. Die Projektliste ' . 'liefert GET /api/bugtracker/v1/projects.' ); } // Ein ausdruecklich genanntes Projekt muss es geben. Frueher wurde ein Tippfehler // klaglos angenommen und das Item mit project_id NULL abgelegt - sichtbar in // keiner Projektansicht, unerreichbar fuer jedes projektgebundene Token. // // Das HEIMATPROJEKT wird bewusst nicht geprueft: Steht in der Token-Bindung ein // Slug ohne dc_projects-Zeile, ist das ein Fehler in der Tokenverwaltung. Eine // Meldung deshalb abzuweisen hiesse, sie zu verlieren - und der Melder koennte // nichts daran aendern. if ($ziel !== $homeProject && !$repo->projectExists($ziel)) { Http::fail( 400, 'unknown_project', sprintf('Das Projekt "%s" gibt es nicht. Bekannte Slugs nennt GET /api/bugtracker/v1/projects.', $ziel), null, ['requested_project' => $ziel, 'home_project' => $homeProject] ); } // --- Fremdmeldung? ---------------------------------------------------------- $target = $ziel; $isForeign = $homeProject !== null && $target !== null && $target !== '' && $target !== $homeProject; // Die Herkunft wird aus der Token-Bindung abgeleitet und niemals aus dem Body // uebernommen. Nur so ist sie bezeugt statt behauptet - und nur dann traegt das // Signal "drei Projekte melden dasselbe" ueberhaupt etwas. $rawEvidence = $data['evidence'] ?? null; unset($data['reported_from'], $data['evidence']); if ($isForeign) { $evidence = is_string($rawEvidence) ? trim($rawEvidence) : null; // Begruendungspflicht. Ein Agent in Projekt X kann kaum unterscheiden, ob // das fremde Projekt kaputt ist oder ob X es falsch benutzt; die // Voreinstellung eines hilfsbereiten Modells ist im Zweifel "melde es". // Etwas ausformulieren zu muessen kostet einen Reflex fast immer den Kopf. if ($evidence === null || mb_strlen($evidence) < MIN_EVIDENCE_LENGTH) { Http::fail( 400, 'missing_evidence', sprintf( 'Eine Meldung gegen das fremde Projekt "%s" verlangt das Feld "evidence" ' . '(mindestens %d Zeichen): was wurde beobachtet, und warum liegt die ' . 'Ursache dort und nicht in "%s"?', $target, MIN_EVIDENCE_LENGTH, $homeProject ), null, ['home_project' => $homeProject, 'target_project' => $target] ); } $data['reported_from'] = $homeProject; $data['evidence'] = $evidence; } // Der Absender wird aus dem Token abgeleitet und kann nicht frei gewaehlt // werden - sonst koennte sich ein Agent als ein anderer ausgeben. $data['created_by'] = $context['actor']; // Idempotenz-Schluessel darf auch als Header kommen. if (empty($data['client_ref'])) { $headerRef = Http::header('idempotency-key'); if ($headerRef !== null) { $data['client_ref'] = $headerRef; } } $result = $repo->reportItem($data); // Bekannt und in einer spaeteren Fassung behoben. Diese Antwort geht jeder // anderen vor: Sie beendet die Frage, statt sie einzusortieren. Der Melder // prueft eine Fassung, in der der Fix noch nicht steckt - er soll aufhoeren zu // suchen und aktualisieren, nicht ein weiteres Item anlegen. // Der Fix hat nicht getragen. Kein neues Item - das bestehende ist zurueck in // der Arbeitsschlange, und der Beleg haengt daran. if (!empty($result['fix_insufficient'])) { $message = sprintf( 'Der Fix traegt nicht. Item #%d steht wieder auf "fix_insufficient" und liegt in der ' . 'Arbeitsschlange - kein neues Item angelegt, der Beleg haengt am bestehenden.', $result['id'] ); } elseif (isset($result['known_fixed_in'])) { $message = sprintf( 'Bekannt und behoben in Build "%s" - du hast "%s" geprueft. Kein neues Item angelegt. ' . 'Aktualisiere und pruefe erneut; tritt es dann immer noch auf, ist es eine Regression ' . 'und gehoert gemeldet.', $result['known_fixed_in'], $result['reported_build'] ); } elseif ($isForeign) { $message = $result['is_new'] ? sprintf( 'Im Eingang von "%s" hinterlegt. Wird dort gesichtet und zaehlt erst nach ' . 'Bestaetigung in die Statistik.', $target ) : sprintf('Bereits bekannt - "%s" ist als betroffen vermerkt.', $homeProject); } else { $message = $result['idempotent_hit'] ? 'Bereits erfasst (identische client_ref) - kein Duplikat angelegt.' : ($result['is_new'] ? ($result['type'] === 'bug' ? 'Bug erfasst.' : 'Feature Request erfasst.') : 'Wiederkehrendes Vorkommnis - Zaehler erhoeht.'); } Http::ok([ 'item_id' => $result['id'], // Wohin die Meldung tatsaechlich ging. Fehlte bisher - deshalb konnte // eine Meldung im Nichts landen, ohne dass die Antwort es verriet. 'project_slug' => $result['project_slug'] ?? null, 'is_new' => $result['is_new'], 'idempotent_hit' => $result['idempotent_hit'], 'occurrence_count' => $result['occurrence_count'], 'dedup_key' => $result['dedup_key'], 'error_hash' => $result['error_hash'], 'type' => $result['type'], 'item_status' => $result['status'], 'environment' => $result['environment'], 'push_id' => $result['push_id'] ?? null, 'regression_of' => $result['regression_of'] ?? null, // Gesetzt, wenn der Fehler bereits in einer spaeteren Fassung behoben ist // als der, die gemeldet wurde. Dann wurde NICHTS angelegt und nichts // hochgezaehlt - die Antwort ist eine Auskunft. 'known_fixed_in' => $result['known_fixed_in'] ?? null, 'reported_build' => $result['reported_build'] ?? null, // Gesetzt, wenn der gemeldete Fehler ein Item betrifft, dessen Fix noch // nicht bestaetigt war. Dann wurde kein neues Item angelegt - das // bestehende wechselte auf fix_insufficient. 'fix_insufficient' => !empty($result['fix_insufficient']), 'reported_from' => $result['reported_from'] ?? null, 'affects' => $result['affects'] ?? [], 'url' => Http::baseUrl() . '/index.php#tab-bugtracker', 'message' => $message, ], $result['is_new'] ? 201 : 200);