fix(security, core): Auth-Pflicht für Ingest-APIs, 500er-Ursachen beheben, Agenten-Workflow

Sicherheit
- install_db.php war ohne Authentifizierung erreichbar und setzte bei jedem
  Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurück.
  Jetzt Auth-Pflicht; ein Konto wird nur bei leerer Benutzertabelle angelegt.
- Stored XSS im Bugtracker-Detail-Modal: Titel, Beschreibung, Fehlermeldung,
  Stacktrace und Kommentare gingen ungefiltert durch innerHTML.
- report.php, projects.php und das Veröffentlichen von Releases verlangen jetzt
  zwingend ein Token. Publish war zuvor völlig ungeschützt.
- CSRF-Token in allen Formularen, Session-Regenerierung nach Login,
  Drosselung fehlgeschlagener Anmeldeversuche.
- Zugangsdaten aus der Versionskontrolle entfernt (Serverdaten.txt,
  config.php, .htpasswd, deploy_config.json). Historie enthält sie weiterhin,
  Rotation erforderlich (siehe docs/UPGRADE.md).
- Token-Validierung nur noch über SHA-256-Hash; expires_at wird ausgewertet.

Behobene 500er
- Audit::log() war in index.php weder eingebunden noch importiert. Jeder
  Klick auf "Aktivierung freigeben" endete in einem Fatal Error.
- Derselbe benannte PDO-Platzhalter mehrfach je Statement (:id in
  revokeToken/deleteToken, :q siebenfach in der Volltextsuche). Bei
  EMULATE_PREPARES=false ist das nicht zulässig und warf HY093.
- Migration 005 nutzte dynamisches SQL, dessen Semikolons in String-Literalen
  vom alten explode(';')-Installer als Statement-Ende gelesen wurden. Sie
  schlug still fehl, wodurch push_id/target_agent/tags dauerhaft fehlten.
- Monitor-Umbenennung ohne Transaktion, verschachtelte Transaktionen im
  RateLimiter.

Funktionale Korrekturen
- Der Watchdog-Evaluator fehlte vollständig: Monitor-Zustände änderten sich nur
  beim Eintreffen eines Heartbeats, ein ausgefallenes System blieb dauerhaft
  "up". Erster Lauf auf dem Produktivsystem: 7 von 10 Monitoren waren
  tatsächlich seit über einem Tag nicht erreichbar.
- Das Feld "os" fehlte im Monitor-Dialog, wurde aber gespeichert und löschte
  damit bei jedem Speichern das Betriebssystem.
- Der Resolve-Dialog existierte im HTML nicht; der Button war funktionslos.
- Versionsvergleich erfolgte lexikografisch, wodurch 1.9.0 als neuer galt
  als 1.10.0.
- Schreiboperationen meldeten Erfolg auch für nicht existierende IDs.
- Post/Redirect/Get gegen doppelte Einträge beim Neuladen.

Neue Struktur
- src/bootstrap.php mit PSR-4-Autoloader ersetzt die require-Ketten.
- Core: Config, Http, Csrf, ApiAuth, Logger, Migrator, ErrorReporter.
- Migrator mit zeichenweisem SQL-Parser, dc_migrations und Baseline-Verfahren,
  damit bestehende Installationen keine Beispieldaten zurückbekommen.

Agenten-Workflow
- Claim/Lease: Items werden exklusiv übernommen, damit nicht zwei Agenten am
  selben Problem arbeiten. action=next holt und reserviert in einem Zug.
- Idempotenz über client_ref, Deduplizierung auch für Feature Requests,
  Erkennung von Regressionen, automatische Eskalation des Schweregrads.
- Strukturierter Code-Kontext (repo_url, commit_sha, file_path, line_no).
- Delta-Abfragen über updated_since, Pagination, Bulk-Update.
- Beim Veröffentlichen eines Releases schließen sich Items mit passendem
  resolved_in_build selbst.
- Ausgehende Webhooks mit HMAC-Signatur, /api/health, /api/openapi.json.
- Unbehandelte Fehler meldet die Plattform in ihren eigenen Bugtracker.

WebUI
- Serverseitige Filterung mit Pagination statt Rendern aller Datensätze.
- Migrations-Schranke, Evaluator-Warnung, Übersicht aktiver Agenten.

Zeitstempel liegen in der Datenbank durchgängig in UTC und werden für die
Anzeige in die App-Zeitzone umgerechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 16:17:36 +02:00
co-authored by Claude Opus 5
parent a21536f495
commit e7fbc85db4
59 changed files with 8506 additions and 2410 deletions
+302 -197
View File
@@ -1,229 +1,334 @@
<?php
/**
* Bugtracker Management-API - die Schnittstelle fuer Coding-Agenten.
*
* Basis: /api/bugtracker/v1/manage
*
* Lesen (Scope bugtracker:read):
* GET ?action=list Gefilterte Liste mit Pagination und Delta-Abfrage
* GET ?action=get&id=42 Einzelnes Item samt Kommentar-Historie
* GET ?action=stats Kennzahlen
* GET ?action=projects Projektliste
*
* Schreiben (Scope bugtracker:manage):
* POST ?action=claim&id=42 Item exklusiv uebernehmen
* POST ?action=next Naechste offene Items holen und uebernehmen
* POST ?action=release&id=42 Item wieder freigeben
* POST ?action=comment&id=42 Kommentar / Ermittlungsschritt anhaengen
* POST ?action=status&id=42 Status setzen
* POST ?action=update&id=42 Mehrere Felder aendern
* POST ?action=resolve&id=42 Als geloest markieren
* POST ?action=bulk_update Mehrere Items auf einmal aendern
*
* ROUTING-AENDERUNG: Die Zuordnung erfolgt jetzt ueber eine feste Aktionsliste.
* Zuvor wurde per str_contains() im Pfad gesucht, wodurch jede URL, die
* zufaellig "/status" oder "/update" enthielt, die Route uebernahm.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../../src/Core/Auth.php';
require_once __DIR__ . '/../../../../../src/Core/Db.php';
require_once __DIR__ . '/../../../../../src/Core/TokenManager.php';
require_once __DIR__ . '/../../../../../src/Modules/Bugtracker/BugRepo.php';
require_once __DIR__ . '/../../../../../src/bootstrap.php';
use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
header('Content-Type: application/json; charset=utf-8');
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
try {
$config = require __DIR__ . '/../../../../../config/config.php';
$db = Db::connect($config['db']);
const READ_ACTIONS = ['list', 'get', 'stats', 'projects'];
const WRITE_ACTIONS = ['claim', 'next', 'release', 'comment', 'status', 'update', 'resolve', 'bulk_update'];
// Authenticate Request (Session OR Token)
$isAuthenticated = false;
$authorName = 'admin';
$db = Db::init();
$repo = new BugRepo($db);
if (Auth::isLoggedIn()) {
$isAuthenticated = true;
$authorName = $_SESSION['dc_username'] ?? 'admin';
} else {
$headers = getallheaders();
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
if (!$token && !empty($headers['Authorization'])) {
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
$token = trim($matches[1]);
}
$action = resolveAction();
$method = Http::method();
if (!in_array($action, READ_ACTIONS, true) && !in_array($action, WRITE_ACTIONS, true)) {
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
'available' => array_merge(READ_ACTIONS, WRITE_ACTIONS),
]);
}
$isWrite = in_array($action, WRITE_ACTIONS, true);
if ($isWrite && $method !== 'POST') {
Http::fail(405, 'method_not_allowed', sprintf('Die Aktion "%s" erwartet POST.', $action));
}
$context = ApiAuth::requireScope($db, $isWrite ? 'bugtracker:manage' : 'bugtracker:read');
$author = $context['actor'];
$boundProject = ApiAuth::projectFilter($context);
$itemId = resolveItemId();
switch ($action) {
// ---------------------------------------------------------------- lesen
case 'projects':
Http::ok(['projects' => $repo->getProjects()]);
// no break - Http::ok beendet die Anfrage
case 'stats':
$slug = Http::str('project_slug') ?? $boundProject;
Http::ok(['stats' => $repo->getStats($slug)]);
case 'get':
requireItemId($itemId);
$item = $repo->getItemDetails($itemId);
if ($item === null) {
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
}
ApiAuth::enforceProject($context, (string)$item['project_slug']);
Http::ok(['item' => $item]);
case 'list':
$filters = collectFilters($boundProject);
$result = $repo->getItems($filters);
Http::ok([
'count' => count($result['items']),
'total' => $result['total'],
'limit' => $result['limit'],
'offset' => $result['offset'],
'has_more' => $result['has_more'],
'filters' => $filters,
'items' => $result['items'],
]);
// -------------------------------------------------------------- schreiben
case 'claim':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
$claimed = $repo->claimItem($itemId, $author, Http::int('lease_minutes', 0) ?: null);
if ($claimed === null) {
Http::fail(409, 'already_claimed', sprintf(
'Item #%d ist bereits vergeben oder nicht mehr offen.',
$itemId
));
}
Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]);
case 'next':
$filters = collectFilters($boundProject);
$limit = Http::int('limit', 1);
$claimedItems = $repo->claimNext($author, $filters, $limit);
Http::ok([
'count' => count($claimedItems),
'items' => $claimedItems,
'message' => $claimedItems === []
? 'Aktuell keine offenen Items verfuegbar.'
: sprintf('%d Item(s) uebernommen.', count($claimedItems)),
]);
case 'release':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
if (!$repo->releaseItem($itemId, $author, Http::str('note'))) {
Http::fail(409, 'not_claimed', sprintf(
'Item #%d ist nicht von "%s" beansprucht.',
$itemId,
$author
));
}
Http::ok(['message' => sprintf('Item #%d freigegeben.', $itemId)]);
case 'comment':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
$comment = Http::str('comment');
if ($comment === null) {
Http::fail(400, 'missing_comment', 'Das Feld "comment" darf nicht leer sein.');
}
if ($token) {
$tokenMgr = new TokenManager($db);
$tokenInfo = $tokenMgr->validateToken($token, 'bugtracker:manage');
if ($tokenInfo) {
$isAuthenticated = true;
$authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']);
}
$meta = Http::input('meta');
$created = $repo->addComment(
$itemId,
$author,
$comment,
Http::str('action_taken') ?? 'commented',
is_array($meta) ? $meta : null
);
Http::ok(['comment' => $created], 201);
case 'status':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
$status = Http::str('status');
if ($status === null) {
Http::fail(400, 'missing_status', 'Das Feld "status" fehlt.', null, [
'allowed' => BugRepo::STATUSES,
]);
}
if (!$repo->updateStatus($itemId, $status, Http::str('notes'), $author)) {
Http::fail(400, 'invalid_status', sprintf(
'Status "%s" ist unbekannt oder Item #%d existiert nicht.',
$status,
$itemId
), null, ['allowed' => BugRepo::STATUSES]);
}
Http::ok(['message' => sprintf('Status von #%d auf "%s" gesetzt.', $itemId, $status)]);
case 'update':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
if (!$repo->updateItemDetails($itemId, Http::body(), $author)) {
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
}
Http::ok(['message' => sprintf('Item #%d aktualisiert.', $itemId)]);
case 'resolve':
requireItemId($itemId);
assertProject($repo, $context, $itemId);
$build = Http::str('resolved_in_build');
if ($build === null) {
Http::fail(400, 'missing_build', 'Das Feld "resolved_in_build" wird benoetigt.');
}
if (!$repo->resolveItem($itemId, $build, Http::str('resolution_notes'), $author)) {
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
}
Http::ok(['message' => sprintf('Item #%d in Build "%s" geloest.', $itemId, $build)]);
case 'bulk_update':
$ids = Http::input('ids');
if (!is_array($ids) || $ids === []) {
Http::fail(400, 'missing_ids', 'Das Feld "ids" muss eine nicht leere Liste sein.');
}
if (count($ids) > 200) {
Http::fail(400, 'too_many_ids', 'Maximal 200 Items pro Aufruf.');
}
$updates = Http::input('updates');
if (!is_array($updates) || $updates === []) {
Http::fail(400, 'missing_updates', 'Das Feld "updates" muss die zu setzenden Felder enthalten.');
}
$result = $repo->bulkUpdate($ids, $updates, $author);
Http::ok([
'updated' => $result['updated'],
'failed' => $result['failed'],
'message' => sprintf('%d Item(s) aktualisiert.', $result['updated']),
]);
}
// ======================================================================
// Hilfsfunktionen
// ======================================================================
/**
* Ermittelt die Aktion aus ?action= oder aus dem letzten Pfadsegment.
* Ohne Angabe: "get" bei vorhandener ID, sonst "list".
*/
function resolveAction(): string
{
$explicit = $_GET['action'] ?? null;
if (is_string($explicit) && $explicit !== '') {
return strtolower(trim($explicit));
}
// Pfadform: /manage/items/42/comment -> "comment"
$path = trim(Http::path(), '/');
$segments = array_values(array_filter(explode('/', $path), static fn(string $s): bool => $s !== ''));
$last = end($segments);
if (is_string($last)) {
$candidate = strtolower($last);
if (in_array($candidate, READ_ACTIONS, true) || in_array($candidate, WRITE_ACTIONS, true)) {
return $candidate;
}
}
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']);
exit;
return resolveItemId() > 0 ? 'get' : 'list';
}
/** Item-ID aus Query, Body oder Pfad (/items/42). */
function resolveItemId(): int
{
$fromRequest = $_GET['id'] ?? null;
if (is_numeric($fromRequest)) {
return (int)$fromRequest;
}
$repo = new BugRepo($db);
$uri = $_SERVER['REQUEST_URI'];
$method = $_SERVER['REQUEST_METHOD'];
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true) ?: $_POST;
// Parse sub-route if any
$path = parse_url($uri, PHP_URL_PATH);
$action = $_GET['action'] ?? null;
// Handle Item Detail/Comment/Resolve via ID in URL or query params
$itemId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$itemId && preg_match('/\/manage\/items\/(\d+)/', $path, $m)) {
$itemId = (int)$m[1];
if (Http::method() === 'POST') {
$body = Http::body();
if (isset($body['id']) && is_numeric($body['id'])) {
return (int)$body['id'];
}
if (isset($body['item_id']) && is_numeric($body['item_id'])) {
return (int)$body['item_id'];
}
}
// Sub-actions
if ($action === 'projects' || str_contains($path, '/projects')) {
echo json_encode(['status' => 'success', 'projects' => $repo->getProjects()], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
if (preg_match('#/items/(\d+)#', Http::path(), $m) === 1) {
return (int)$m[1];
}
if ($action === 'stats' || str_ends_with($path, '/stats')) {
echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT);
exit;
return 0;
}
function requireItemId(int $itemId): void
{
if ($itemId <= 0) {
Http::fail(400, 'missing_id', 'Es wurde keine Item-ID uebergeben (?id=... oder /items/<id>).');
}
}
/** Stellt sicher, dass ein projektgebundenes Token das Item anfassen darf. */
function assertProject(BugRepo $repo, array $context, int $itemId): void
{
if (ApiAuth::projectFilter($context) === null) {
return;
}
if ($action === 'resolve' || str_contains($path, '/resolve')) {
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'POST required for resolve']);
exit;
}
if (!$itemId) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
exit;
}
$build = !empty($input['resolved_in_build']) ? trim($input['resolved_in_build']) : 'v1.0.0';
$notes = !empty($input['resolution_notes']) ? trim($input['resolution_notes']) : null;
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
$ok = $repo->resolveItem($itemId, $build, $notes, $author);
if ($ok) {
echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} resolved in build {$build}"]);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Failed to resolve item']);
}
exit;
$item = $repo->getItemDetails($itemId);
if ($item === null) {
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
}
if ($action === 'comment' || str_contains($path, '/comments')) {
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'POST required for comment']);
exit;
}
ApiAuth::enforceProject($context, (string)$item['project_slug']);
}
if (!$itemId) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
exit;
}
$comment = !empty($input['comment']) ? trim($input['comment']) : '';
if (empty($comment)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Comment cannot be empty']);
exit;
}
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
$actionTaken = !empty($input['action_taken']) ? trim($input['action_taken']) : 'commented';
$meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : null;
$comm = $repo->addComment($itemId, $author, $comment, $actionTaken, $meta);
echo json_encode(['status' => 'success', 'comment' => $comm]);
exit;
}
if ($action === 'status' || str_contains($path, '/status')) {
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'POST required for status change']);
exit;
}
if (!$itemId) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
exit;
}
$status = !empty($input['status']) ? trim($input['status']) : 'open';
$notes = !empty($input['notes']) ? trim($input['notes']) : null;
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
$ok = $repo->updateStatus($itemId, $status, $notes, $author);
if ($ok) {
echo json_encode(['status' => 'success', 'message' => "Status for #{$itemId} updated to {$status}"]);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid status']);
}
exit;
}
if ($action === 'update' || str_contains($path, '/update')) {
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'POST required for update']);
exit;
}
if (!$itemId && !empty($input['id'])) {
$itemId = (int)$input['id'];
}
if (!$itemId) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
exit;
}
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
$ok = $repo->updateItemDetails($itemId, $input, $author);
if ($ok) {
echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} updated successfully"]);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Failed to update item']);
}
exit;
}
// Detail View of a single item
if ($itemId > 0 && $method === 'GET') {
$details = $repo->getItemDetails($itemId);
if (!$details) {
http_response_code(404);
echo json_encode(['status' => 'error', 'message' => 'Item not found']);
exit;
}
echo json_encode(['status' => 'success', 'item' => $details], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
// Default: List Items
/**
* Sammelt Filter aus Query und Body.
*
* @return array<string,mixed>
*/
function collectFilters(?string $boundProject): array
{
$filters = [
'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all',
'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all',
'type' => $_GET['type'] ?? 'all',
'status' => $_GET['status'] ?? 'all',
'severity' => $_GET['severity'] ?? 'all',
'push_id' => $_GET['push_id'] ?? '',
'target_agent' => $_GET['target_agent'] ?? $_GET['agent'] ?? '',
'search' => $_GET['search'] ?? $_GET['q'] ?? '',
'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? 'all',
'environment' => Http::str('environment') ?? Http::str('env') ?? 'all',
'type' => Http::str('type') ?? 'all',
'status' => Http::str('status') ?? 'all',
'severity' => Http::str('severity') ?? 'all',
'push_id' => Http::str('push_id') ?? '',
'target_agent' => Http::str('target_agent') ?? Http::str('agent') ?? '',
'claimed_by' => Http::str('claimed_by') ?? '',
'search' => Http::str('search') ?? Http::str('q') ?? '',
'updated_since' => Http::str('updated_since') ?? '',
'order' => Http::str('order') ?? 'newest',
'limit' => Http::int('limit', 100),
'offset' => Http::int('offset', 0),
];
$items = $repo->getItems($filters);
echo json_encode([
'status' => 'success',
'count' => count($items),
'filters' => $filters,
'items' => $items,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (Http::input('unclaimed_only') !== null) {
$filters['unclaimed_only'] = filter_var(Http::input('unclaimed_only'), FILTER_VALIDATE_BOOLEAN);
}
} catch (Throwable $t) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]);
// Ein projektgebundenes Token kann den Projektfilter nicht umgehen.
if ($boundProject !== null) {
$filters['project_slug'] = $boundProject;
}
return $filters;
}
+43 -30
View File
@@ -1,44 +1,57 @@
<?php
/**
* GET /api/bugtracker/v1/projects
*
* Projekt-Discovery fuer Agenten: welche Projekte gibt es, wie heissen ihre
* Slugs, wo liegt das Repository und wie viele Items sind offen.
*
* SICHERHEITSAENDERUNG: verlangt jetzt ein Token mit "bugtracker:read"
* (oder hoeher). Zuvor war die Projektliste oeffentlich abrufbar.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/Core/Db.php';
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
header('Access-Control-Allow-Methods: GET, OPTIONS');
Http::beginJson(['GET', 'OPTIONS'], true);
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
if (Http::method() !== 'GET') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet GET.');
}
try {
$config = require __DIR__ . '/../../../../config/config.php';
$db = Db::connect($config['db']);
$db = Db::init();
$context = ApiAuth::requireScope($db, 'bugtracker:read');
$repo = new BugRepo($db);
$projects = $repo->getProjects();
$repo = new BugRepo($db);
$projects = $repo->getProjects();
echo json_encode([
'status' => 'success',
'count' => count($projects),
'projects' => array_map(function($p) {
return [
'id' => (int)$p['id'],
'slug' => $p['slug'],
'name' => $p['name'],
'notes' => $p['notes'] ?? null,
];
}, $projects),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (Throwable $t) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Failed to fetch projects: ' . $t->getMessage()]);
// Ein projektgebundenes Token sieht nur sein eigenes Projekt.
$bound = ApiAuth::projectFilter($context);
if ($bound !== null) {
$projects = array_values(array_filter(
$projects,
static fn(array $p): bool => (string)$p['slug'] === $bound
));
}
Http::ok([
'count' => count($projects),
'projects' => array_map(static function (array $p): array {
return [
'id' => (int)$p['id'],
'slug' => (string)$p['slug'],
'name' => (string)$p['name'],
'notes' => $p['notes'] ?? null,
'repo_url' => $p['repo_url'] ?? null,
'default_agent' => $p['default_agent'] ?? null,
'open_items' => (int)($p['open_items'] ?? 0),
'critical_items' => (int)($p['critical_items'] ?? 0),
];
}, $projects),
]);
+77 -65
View File
@@ -1,83 +1,95 @@
<?php
/**
* POST /api/bugtracker/v1/report
*
* Nimmt Bugs, Feature Requests und Ideen von Agenten und Client-Anwendungen
* entgegen.
*
* SICHERHEITSAENDERUNG: Dieser Endpunkt verlangt jetzt zwingend ein Token mit
* dem Recht "bugtracker:report". Zuvor wurde ein Token nur geprueft, wenn eines
* mitgeschickt wurde - damit konnte jeder im Internet Eintraege anlegen
* (und ueber die Detailansicht Skripte in die Admin-Session einschleusen).
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/Core/Db.php';
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\License\RateLimiter;
header('Content-Type: application/json; charset=utf-8');
Http::beginJson(['POST', 'OPTIONS'], true);
// Allow CORS for public ingest
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
header('Access-Control-Allow-Methods: POST, OPTIONS');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
exit;
$db = Db::init();
// Drosselung, damit ein Agent in einer Fehlerschleife den Tracker nicht flutet.
$limiter = new RateLimiter($db, (int)Config::get('bugtracker.report_rate', 60), 60, 'bt_report');
if (!$limiter->check(Http::clientIp())) {
Http::fail(429, 'rate_limited', 'Zu viele Reports. Bitte Sendefrequenz reduzieren.');
}
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true) ?: $_POST;
if (empty($data)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']);
exit;
$data = Http::body();
if ($data === []) {
Http::fail(400, 'empty_body', 'Der Request-Body ist leer.');
}
try {
$config = require __DIR__ . '/../../../../config/config.php';
$db = Db::connect($config['db']);
$environment = is_string($data['environment'] ?? null) ? $data['environment'] : null;
// Optional Token Verification (if provided)
$headers = getallheaders();
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
if (!$token && !empty($headers['Authorization'])) {
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
$token = trim($matches[1]);
}
// 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;
ApiAuth::enforceProject($context, $projectSlug);
// Ein projektgebundenes Token schreibt immer in sein eigenes Projekt.
$boundProject = ApiAuth::projectFilter($context);
if ($boundProject !== null) {
$data['project_slug'] = $boundProject;
}
// 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;
}
if ($token) {
$tokenMgr = new TokenManager($db);
$valid = $tokenMgr->validateToken($token, 'bugtracker:report', $data['environment'] ?? null);
if (!$valid) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Invalid, revoked or unauthorized Token for bugtracker:report']);
exit;
}
}
$repo = new BugRepo($db);
$result = $repo->reportItem($data);
echo json_encode([
'status' => 'success',
'item_id' => $result['id'],
'is_new' => $result['is_new'],
'occurrence_count' => $result['occurrence_count'],
'error_hash' => $result['error_hash'],
'type' => $result['type'],
'environment' => $result['environment'],
'push_id' => $result['push_id'] ?? null,
'message' => $result['is_new']
? ($result['type'] === 'bug' ? 'New bug reported successfully.' : 'New feature request / idea submitted.')
: 'Recurring bug count updated.',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (Throwable $t) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Failed to log report: ' . $t->getMessage()]);
}
$repo = new BugRepo($db);
$result = $repo->reportItem($data);
$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'],
'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,
'url' => Http::baseUrl() . '/index.php#tab-bugtracker',
'message' => $message,
], $result['is_new'] ? 201 : 200);