diff --git a/.gitignore b/.gitignore index 3ca00a5..ee4c9de 100755 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ settings.local.json # Upload files (keep folder structure, ignore actual uploads) uploads/* !uploads/.gitkeep -!uploads/.htaccess \ No newline at end of file +!uploads/.htaccess +# Composer dependencies (used only by the MCP endpoint; installed at deploy time) +vendor/ diff --git a/.phpcs.xml b/.phpcs.xml index 58389b9..74d6774 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -6,6 +6,7 @@ */uploads/* */migrations/* */.gitea/* + */vendor/* diff --git a/api/add_comment.php b/api/add_comment.php index 0a17e8b..9e14e65 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -80,155 +80,22 @@ try { exit; } - $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; - if (!ctype_digit($ticketId) || (int)$ticketId <= 0) { - http_response_code(400); + // Validation, access check, mentions, audit log and notifications live in + // CommentService so the MCP add_comment tool runs the same code path. + require_once dirname(__DIR__) . '/services/CommentService.php'; + $result = CommentService::addComment($conn, $currentUser, $data); + + if (!empty($result['http_status'])) { + http_response_code($result['http_status']); + unset($result['http_status']); ob_end_clean(); header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']); + echo json_encode($result); exit; } - // Reject empty/whitespace-only comments - $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; - if ($commentTextRaw === '') { - http_response_code(400); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']); - exit; - } - - // Persist the trimmed text (not the raw client value) — matches update_comment.php - // and keeps stored comment_text free of leading whitespace that could shift a - // markdown-enabled comment's first line out of column 0 on reload. - $data['comment_text'] = $commentTextRaw; - - // Never trust a client-supplied display name — always attribute the comment to - // the authenticated session user. - $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; - - // Verify user can access the ticket before allowing a comment - $ticketModel = new TicketModel($conn); - $ticket = $ticketModel->getTicketById($ticketId); - if (!$ticket) { - http_response_code(404); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Ticket not found']); - exit; - } - if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) { - http_response_code(403); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Access denied']); - exit; - } - - // Initialize models - $commentModel = new CommentModel($conn); - $auditLog = new AuditLogModel($conn); - - // If replying, the parent comment must belong to this same (accessible) ticket. - if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') { - $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']); - if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) { - http_response_code(400); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid parent comment']); - exit; - } - } - - // Extract @mentions from comment text - $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); - $mentionedUsers = []; - if (!empty($mentions)) { - $mentionedUsers = $commentModel->getMentionedUsers($mentions); - } - - // Add comment with user tracking - $result = $commentModel->addComment($ticketId, $data, $userId); - - // Log comment creation to audit log - if ($result['success'] && isset($result['comment_id'])) { - $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); - - // Log mentions to audit log - foreach ($mentionedUsers as $mentionedUser) { - $auditLog->log( - $userId, - 'mention', - 'user', - (string)$mentionedUser['user_id'], - [ - 'ticket_id' => $ticketId, - 'comment_id' => $result['comment_id'], - 'mentioned_username' => $mentionedUser['username'] - ] - ); - } - - // Matrix notifications - $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; - $commentText = $data['comment_text'] ?? ''; - $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; - $ticketVisibility = $ticket['visibility'] ?? 'public'; - - // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API. - // Only notify mentioned users who actually have access to this ticket; - // otherwise a mention would DM them the ticket's title and comment text - // even though canUserAccessTicket() would deny them the ticket itself. - $accessibleMentionedUsers = array_filter( - $mentionedUsers, - fn($u) => $ticketModel->canUserAccessTicket($ticket, $u) - ); - if (!empty($accessibleMentionedUsers)) { - $mentionedUsernames = array_column($accessibleMentionedUsers, 'username'); - $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); - if (!empty($mentionedMatrixIds)) { - NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); - } - } - - // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) - if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { - NotificationHelper::sendCommentNotification( - $ticketId, - $ticketTitle, - $commentText, - $authorDisplay, - $ticketVisibility !== 'public', - $ticketVisibility - ); - } - - // Notify watchers of the new comment - NotificationHelper::notifyWatchers( - $conn, - $ticketId, - $ticketTitle, - 'comment_added', - ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], - (int)$userId, - $ticketVisibility - ); - - // Add mentioned users to result for frontend - $result['mentions'] = array_map(function ($u) { - return $u['username']; - }, $mentionedUsers); - } - - // Add user info to result for frontend avatar rendering - if ($result['success']) { - $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; - $result['user_id'] = $userId; - if (isset($newCsrfToken)) { - $result['csrf_token'] = $newCsrfToken; - } + if ($result['success'] && isset($newCsrfToken)) { + $result['csrf_token'] = $newCsrfToken; } // Discard any unexpected output diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 1046909..28ced6c 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -1,11 +1,7 @@ false, 'error' => 'Ticket ID required']); - exit; -} -$ticketId = $ticketIdRaw; - -$ticketModel = new TicketModel($conn); -$auditLogModel = new AuditLogModel($conn); -$userModel = new UserModel($conn); - -// Verify ticket exists and user can access it -$ticket = $ticketModel->getTicketById($ticketId); -if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { - http_response_code(404); - echo json_encode(['success' => false, 'error' => 'Ticket not found']); - exit; -} - -// Authorization: only admins or the ticket creator/assignee can reassign -if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Permission denied']); - exit; -} - -if ($assignedTo === null || $assignedTo === '') { - // Unassign ticket - $success = $ticketModel->unassignTicket($ticketId, $userId); - if ($success) { - $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); - } -} else { - // Validate assigned_to is a valid user ID - $assignedTo = (int)$assignedTo; - $targetUser = $userModel->getUserById($assignedTo); - if (!$targetUser) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid user ID']); - exit; - } - - // Assign ticket - $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); - if ($success) { - $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); - - if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) { - $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; - $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null; - $assigneeMatrix = isset($targetUser['username']) - ? SynapseHelper::resolveUsername($targetUser['username']) - : null; - NotificationHelper::sendAssignmentNotification( - $ticketId, - $ticket['title'] ?? "Ticket #{$ticketId}", - $assigneeName, - $assigneeMatrix, - $changedByDisplay, - $ticket['visibility'] ?? 'public' - ); - } - } -} - -if (!$success) { - http_response_code(500); - apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']); -} else { - require_once dirname(__DIR__) . '/models/StatsModel.php'; - (new StatsModel($conn))->invalidateCache(); - apiRespond(['success' => true]); +if (!empty($result['http_status'])) { + http_response_code($result['http_status']); + unset($result['http_status']); } +apiRespond($result); diff --git a/api/update_ticket.php b/api/update_ticket.php index 08c4c22..c8825b0 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -62,262 +62,7 @@ try { $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; - // Updated controller class that handles partial updates - class ApiTicketController - { - private $conn; - private $ticketModel; - private $commentModel; - private $auditLog; - private $workflowModel; - private $userId; - private $isAdmin; - private $currentUser; - - public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) - { - $this->conn = $conn; - $this->ticketModel = new TicketModel($conn); - $this->commentModel = new CommentModel($conn); - $this->auditLog = new AuditLogModel($conn); - $this->workflowModel = new WorkflowModel($conn); - $this->userId = $userId; - $this->isAdmin = $isAdmin; - $this->currentUser = $currentUser; - } - - public function update($id, $data) - { - // First, get the current ticket data to fill in missing fields - $currentTicket = $this->ticketModel->getTicketById($id); - if (!$currentTicket) { - return [ - 'success' => false, - 'error' => 'Ticket not found' - ]; - } - - // Visibility check: return 404 for tickets the user cannot access - if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) { - return [ - 'success' => false, - 'error' => 'Ticket not found', - 'http_status' => 404 - ]; - } - - // Any authenticated team member can update tickets. - // Admin-only operations (delete, bulk actions) are enforced separately. - - // Merge current data with updates, keeping existing values for missing fields - $updateData = [ - 'ticket_id' => $id, - 'title' => $data['title'] ?? $currentTicket['title'], - 'description' => $data['description'] ?? $currentTicket['description'], - 'category' => $data['category'] ?? $currentTicket['category'], - 'type' => $data['type'] ?? $currentTicket['type'], - 'status' => $data['status'] ?? $currentTicket['status'], - 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] - ]; - - // Validate required fields - if (empty($updateData['title'])) { - return [ - 'success' => false, - 'error' => 'Title cannot be empty', - 'http_status' => 400 - ]; - } - - // Validate priority range - if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { - return [ - 'success' => false, - 'error' => 'Priority must be between 1 and 5', - 'http_status' => 400 - ]; - } - - // Validate visibility BEFORE any DB write so a bad payload can't leave the - // ticket half-updated (core fields committed but request reported as failed). - $visibilityGroups = null; - if (isset($data['visibility'])) { - $visibilityGroups = $data['visibility_groups'] ?? null; - // Convert array to comma-separated string if needed - if (is_array($visibilityGroups)) { - $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); - } - - // Authorization: only an admin or the ticket's creator may change - // visibility. Enforce only when the requested visibility actually - // differs so ordinary edits that re-send the same value aren't blocked. - $currentVisibility = $currentTicket['visibility'] ?? 'public'; - $currentGroups = $currentTicket['visibility_groups'] ?? null; - $groupsProvided = array_key_exists('visibility_groups', $data); - $visibilityChanged = ($data['visibility'] !== $currentVisibility) - || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups); - if ($visibilityChanged) { - $isCreator = $this->userId !== null - && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId; - if (!$this->isAdmin && !$isCreator) { - return [ - 'success' => false, - 'error' => 'You do not have permission to change ticket visibility', - 'http_status' => 403 - ]; - } - } - - // Internal visibility requires at least one group - if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { - return [ - 'success' => false, - 'error' => 'Internal visibility requires at least one group to be specified', - 'http_status' => 400 - ]; - } - } - - // Validate status transition using workflow model - if ($currentTicket['status'] !== $updateData['status']) { - $allowed = $this->workflowModel->isTransitionAllowed( - $currentTicket['status'], - $updateData['status'], - $this->isAdmin - ); - - if (!$allowed) { - return [ - 'success' => false, - 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] - ]; - } - - // Enforce requires_comment transitions server-side. - if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { - $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); - if ($statusChangeComment === '') { - return [ - 'success' => false, - 'error' => 'A comment is required for this status change', - 'requires_comment' => true, - 'http_status' => 400 - ]; - } - } - } - - // A comment accompanying a status change (required or optional) is - // persisted in the SAME transaction as the status update below, so - // a failure partway through can't leave an orphaned "reason" - // comment attached with no matching status change — the two - // previously ran as separate, non-transactional HTTP calls from - // the client (add_comment.php then update_ticket.php). - $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); - - $result = null; - $this->conn->begin_transaction(); - try { - if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) { - $commentResult = $this->commentModel->addComment($id, [ - 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User', - 'comment_text' => $statusChangeComment, - 'markdown_enabled' => !empty($data['markdown_enabled']), - ], $this->userId); - if (empty($commentResult['success'])) { - throw new Exception($commentResult['error'] ?? 'Failed to add comment'); - } - } - - // Update ticket with user tracking and optional optimistic locking - $expectedUpdatedAt = $data['expected_updated_at'] ?? null; - $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); - if (!$result['success']) { - throw new Exception($result['error'] ?? 'Failed to update ticket in database'); - } - - // Handle visibility update if provided (already validated above) - if (isset($data['visibility'])) { - $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); - if (!$visResult) { - throw new Exception('Failed to update ticket visibility'); - } - } - - $this->conn->commit(); - } catch (Exception $e) { - $this->conn->rollback(); - $response = ['success' => false, 'error' => $e->getMessage()]; - if (is_array($result) && !empty($result['conflict'])) { - $response['conflict'] = true; - $response['current_updated_at'] = $result['current_updated_at'] ?? null; - } - return $response; - } - - if (isset($data['visibility']) && $this->userId) { - $this->auditLog->log( - $this->userId, - 'update', - 'ticket', - (string)$id, - [ - 'field' => 'visibility', - 'from' => $currentTicket['visibility'] ?? 'public', - 'to' => $data['visibility'], - 'groups' => $visibilityGroups - ] - ); - } - - // Log ticket update to audit log — only the changed fields (delta) - if ($this->userId) { - $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type']; - $delta = []; - foreach ($trackFields as $field) { - $oldVal = (string)($currentTicket[$field] ?? ''); - $newVal = (string)($updateData[$field] ?? ''); - if ($oldVal !== $newVal) { - $delta[$field] = ['from' => $oldVal, 'to' => $newVal]; - } - } - if (!empty($delta)) { - $this->auditLog->logTicketUpdate($this->userId, $id, $delta); - } - } - - // Notify on status change (global notify list + watchers) - if ($currentTicket['status'] !== $updateData['status']) { - $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; - NotificationHelper::sendStatusChangeNotification( - $id, - $currentTicket['status'], - $updateData['status'], - $updateData['title'], - $changedBy, - $currentTicket['visibility'] ?? 'public' - ); - NotificationHelper::notifyWatchers( - $this->conn, - $id, - $updateData['title'], - 'status_changed', - ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], - (int)$this->userId, - $currentTicket['visibility'] ?? 'public' - ); - } - - return [ - 'success' => true, - 'status' => $updateData['status'], - 'priority' => $updateData['priority'], - 'updated_at' => date('Y-m-d H:i:s'), - 'message' => 'Ticket updated successfully', - 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null - ]; - } - } + require_once dirname(__DIR__) . '/controllers/ApiTicketController.php'; // Use centralized database connection $conn = Database::getConnection(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..59dcc71 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "lotusguild/tinker-tickets", + "description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.2", + "ext-openssl": "*", + "firebase/php-jwt": "^7.0", + "laminas/laminas-httphandlerrunner": "^2.12", + "mcp/sdk": "0.8.1", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "psr/simple-cache": "^3.0", + "symfony/cache": "^7.3", + "symfony/http-client": "^7.3" + }, + "autoload": { + "psr-4": { + "TinkerTickets\\Mcp\\": "mcp/src/" + } + }, + "config": { + "platform": { + "php": "8.2.0" + }, + "sort-packages": true, + "optimize-autoloader": true, + "allow-plugins": { + "php-http/discovery": false + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..5815cf3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2421 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "dd83586e9ecd3408852359dc0e84a4be", + "packages": [ + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "firebase/php-jwt", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/f502cdbf279cd7532060b041f7c22a05208c0b93", + "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4||^8.0", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "extra": { + "component": { + "id": "jwt", + "path": "Jwt", + "entry": "README.md", + "target": "googleapis/php-jwt.git" + } + }, + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.2.0" + }, + "time": "2026-09-21T19:29:58+00:00" + }, + { + "name": "laminas/laminas-httphandlerrunner", + "version": "2.14.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-httphandlerrunner.git", + "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/14d5182a4ba180c998dfbba9d19f9a70337c02b4", + "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-message-implementation": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "phpunit/phpunit": "^10.5.46", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.3" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "homepage": "https://laminas.dev", + "keywords": [ + "components", + "laminas", + "mezzio", + "psr-15", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", + "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", + "source": "https://github.com/laminas/laminas-httphandlerrunner" + }, + "funding": [ + { + "url": "https://crowdfunding.linuxfoundation.org/initiatives/laminas-project", + "type": "custom" + } + ], + "time": "2026-09-21T10:14:52+00:00" + }, + { + "name": "mcp/sdk", + "version": "v0.8.1", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e", + "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "php": "^8.1", + "php-http/discovery": "^1.20", + "phpdocumentor/reflection-docblock": "^5.6 || ^6.0", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "composer/semver": "^3.0", + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phar-io/composer-distributor": "^1.0.2", + "php-cs-fixer/shim": "^3.91", + "phpdocumentor/shim": "^3", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/simple-cache": "^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "suggest": { + "symfony/finder": "Required for file-based discovery." + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "support": { + "issues": "https://github.com/modelcontextprotocol/php-sdk/issues", + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.8.1" + }, + "time": "2026-08-29T23:10:56+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c", + "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:28:43+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:28:43+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-11T07:38:58+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-openssl": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/config/config.php b/config/config.php index 99454b9..1bc3543 100644 --- a/config/config.php +++ b/config/config.php @@ -102,6 +102,14 @@ $GLOBALS['config'] = [ explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1') )), + // MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's + // canonical URL: access tokens must carry it as their audience, so a + // beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the + // Authelia issuer that signs those tokens. + 'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL'] + ?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null), + 'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org', + // Session settings 'SESSION_TIMEOUT' => 18000, // 5 hours in seconds 'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes diff --git a/controllers/ApiTicketController.php b/controllers/ApiTicketController.php new file mode 100644 index 0000000..9a47bf6 --- /dev/null +++ b/controllers/ApiTicketController.php @@ -0,0 +1,275 @@ +conn = $conn; + $this->ticketModel = new TicketModel($conn); + $this->commentModel = new CommentModel($conn); + $this->auditLog = new AuditLogModel($conn); + $this->workflowModel = new WorkflowModel($conn); + $this->userId = $userId; + $this->isAdmin = $isAdmin; + $this->currentUser = $currentUser; + } + + public function update($id, $data) + { + // First, get the current ticket data to fill in missing fields + $currentTicket = $this->ticketModel->getTicketById($id); + if (!$currentTicket) { + return [ + 'success' => false, + 'error' => 'Ticket not found' + ]; + } + + // Visibility check: return 404 for tickets the user cannot access + if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) { + return [ + 'success' => false, + 'error' => 'Ticket not found', + 'http_status' => 404 + ]; + } + + // Any authenticated team member can update tickets. + // Admin-only operations (delete, bulk actions) are enforced separately. + + // Merge current data with updates, keeping existing values for missing fields + $updateData = [ + 'ticket_id' => $id, + 'title' => $data['title'] ?? $currentTicket['title'], + 'description' => $data['description'] ?? $currentTicket['description'], + 'category' => $data['category'] ?? $currentTicket['category'], + 'type' => $data['type'] ?? $currentTicket['type'], + 'status' => $data['status'] ?? $currentTicket['status'], + 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] + ]; + + // Validate required fields + if (empty($updateData['title'])) { + return [ + 'success' => false, + 'error' => 'Title cannot be empty', + 'http_status' => 400 + ]; + } + + // Validate priority range + if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { + return [ + 'success' => false, + 'error' => 'Priority must be between 1 and 5', + 'http_status' => 400 + ]; + } + + // Validate visibility BEFORE any DB write so a bad payload can't leave the + // ticket half-updated (core fields committed but request reported as failed). + $visibilityGroups = null; + if (isset($data['visibility'])) { + $visibilityGroups = $data['visibility_groups'] ?? null; + // Convert array to comma-separated string if needed + if (is_array($visibilityGroups)) { + $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); + } + + // Authorization: only an admin or the ticket's creator may change + // visibility. Enforce only when the requested visibility actually + // differs so ordinary edits that re-send the same value aren't blocked. + $currentVisibility = $currentTicket['visibility'] ?? 'public'; + $currentGroups = $currentTicket['visibility_groups'] ?? null; + $groupsProvided = array_key_exists('visibility_groups', $data); + $visibilityChanged = ($data['visibility'] !== $currentVisibility) + || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups); + if ($visibilityChanged) { + $isCreator = $this->userId !== null + && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId; + if (!$this->isAdmin && !$isCreator) { + return [ + 'success' => false, + 'error' => 'You do not have permission to change ticket visibility', + 'http_status' => 403 + ]; + } + } + + // Internal visibility requires at least one group + if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { + return [ + 'success' => false, + 'error' => 'Internal visibility requires at least one group to be specified', + 'http_status' => 400 + ]; + } + } + + // Validate status transition using workflow model + if ($currentTicket['status'] !== $updateData['status']) { + $allowed = $this->workflowModel->isTransitionAllowed( + $currentTicket['status'], + $updateData['status'], + $this->isAdmin + ); + + if (!$allowed) { + return [ + 'success' => false, + 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] + ]; + } + + // Enforce requires_comment transitions server-side. + if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { + $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); + if ($statusChangeComment === '') { + return [ + 'success' => false, + 'error' => 'A comment is required for this status change', + 'requires_comment' => true, + 'http_status' => 400 + ]; + } + } + } + + // A comment accompanying a status change (required or optional) is + // persisted in the SAME transaction as the status update below, so + // a failure partway through can't leave an orphaned "reason" + // comment attached with no matching status change — the two + // previously ran as separate, non-transactional HTTP calls from + // the client (add_comment.php then update_ticket.php). + $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); + + $result = null; + $this->conn->begin_transaction(); + try { + if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) { + $commentResult = $this->commentModel->addComment($id, [ + 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User', + 'comment_text' => $statusChangeComment, + 'markdown_enabled' => !empty($data['markdown_enabled']), + ], $this->userId); + if (empty($commentResult['success'])) { + throw new Exception($commentResult['error'] ?? 'Failed to add comment'); + } + } + + // Update ticket with user tracking and optional optimistic locking + $expectedUpdatedAt = $data['expected_updated_at'] ?? null; + $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); + if (!$result['success']) { + throw new Exception($result['error'] ?? 'Failed to update ticket in database'); + } + + // Handle visibility update if provided (already validated above) + if (isset($data['visibility'])) { + $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); + if (!$visResult) { + throw new Exception('Failed to update ticket visibility'); + } + } + + $this->conn->commit(); + } catch (Exception $e) { + $this->conn->rollback(); + $response = ['success' => false, 'error' => $e->getMessage()]; + if (is_array($result) && !empty($result['conflict'])) { + $response['conflict'] = true; + $response['current_updated_at'] = $result['current_updated_at'] ?? null; + } + return $response; + } + + if (isset($data['visibility']) && $this->userId) { + $this->auditLog->log( + $this->userId, + 'update', + 'ticket', + (string)$id, + [ + 'field' => 'visibility', + 'from' => $currentTicket['visibility'] ?? 'public', + 'to' => $data['visibility'], + 'groups' => $visibilityGroups + ] + ); + } + + // Log ticket update to audit log — only the changed fields (delta) + if ($this->userId) { + $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type']; + $delta = []; + foreach ($trackFields as $field) { + $oldVal = (string)($currentTicket[$field] ?? ''); + $newVal = (string)($updateData[$field] ?? ''); + if ($oldVal !== $newVal) { + $delta[$field] = ['from' => $oldVal, 'to' => $newVal]; + } + } + if (!empty($delta)) { + $this->auditLog->logTicketUpdate($this->userId, $id, $delta); + } + } + + // Notify on status change (global notify list + watchers) + if ($currentTicket['status'] !== $updateData['status']) { + $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; + NotificationHelper::sendStatusChangeNotification( + $id, + $currentTicket['status'], + $updateData['status'], + $updateData['title'], + $changedBy, + $currentTicket['visibility'] ?? 'public' + ); + NotificationHelper::notifyWatchers( + $this->conn, + $id, + $updateData['title'], + 'status_changed', + ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], + (int)$this->userId, + $currentTicket['visibility'] ?? 'public' + ); + } + + return [ + 'success' => true, + 'status' => $updateData['status'], + 'priority' => $updateData['priority'], + 'updated_at' => date('Y-m-d H:i:s'), + 'message' => 'Ticket updated successfully', + 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null + ]; + } +} diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 2825d29..fd4dc51 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -101,115 +101,27 @@ class TicketController return; } - // Handle visibility groups (comes as array from checkboxes) - $visibilityGroups = null; - if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) { - $visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups'])); - } - - // Honor the posted status, validated against the app's canonical list - $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; - $status = $_POST['status'] ?? 'Open'; - if (!in_array($status, $validStatuses, true)) { - $status = 'Open'; - } - - $ticketData = [ - 'title' => trim($_POST['title'] ?? ''), + // Validation (incl. required custom fields), creation, audit log, + // stats cache, custom field values, duplicate link and notification + // live in TicketCreationService so the MCP create_ticket tool runs + // the same code path. + require_once dirname(__DIR__) . '/services/TicketCreationService.php'; + $result = TicketCreationService::create($this->conn, $currentUser ?? [], [ + 'title' => $_POST['title'] ?? '', 'description' => $_POST['description'] ?? '', 'priority' => $_POST['priority'] ?? '4', 'category' => $_POST['category'] ?? 'General', 'type' => $_POST['type'] ?? 'Issue', - 'status' => $status, + 'status' => $_POST['status'] ?? 'Open', 'visibility' => $_POST['visibility'] ?? 'public', - 'visibility_groups' => $visibilityGroups, - 'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null - ]; - - // Validate input (server-side; form is novalidate) - if ($ticketData['title'] === '') { - $error = "Title is required"; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; // Make $conn available to view - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - if (trim($ticketData['description']) === '') { - $error = "Description is required"; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; // Make $conn available to view - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - // Custom fields applicable to the submitted category — validate - // is_required server-side (the form is novalidate, and a field - // hidden by the client-side category toggle must not silently - // bypass a requirement that applies to the category actually - // submitted). - $submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : []; - $applicableFieldDefs = array_filter( - $allCustomFieldDefs, - fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] - ); - $customFieldsToSave = []; - foreach ($applicableFieldDefs as $def) { - $fieldId = (int)$def['field_id']; - $raw = $submittedCustomFields[$fieldId] ?? null; - $normalized = $def['field_type'] === 'checkbox' - ? (!empty($raw) ? '1' : '0') - : (is_scalar($raw) ? trim((string)$raw) : ''); - - if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { - $error = $def['field_label'] . ' is required'; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - if ($normalized !== '') { - $customFieldsToSave[$fieldId] = $normalized; - } - } - - // Create ticket with user tracking - $result = $this->ticketModel->createTicket($ticketData, $userId); + 'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) + ? $_POST['visibility_groups'] : null, + 'assigned_to' => $_POST['assigned_to'] ?? null, + 'custom_fields' => $_POST['custom_fields'] ?? [], + 'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '', + ]); if ($result['success']) { - // Log ticket creation to audit log - if (isset($GLOBALS['auditLog']) && $userId) { - $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); - } - - // Ticket counts changed — invalidate the cached dashboard stats - require_once dirname(__DIR__) . '/models/StatsModel.php'; - (new StatsModel($this->conn))->invalidateCache(); - - // Persist custom field values for the fields applicable to - // this ticket's category - if (!empty($customFieldsToSave)) { - $this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); - } - - // Auto-link as duplicate if requested from create form - $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); - if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { - $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by) - VALUES (?, ?, 'duplicates', ?)"; - $depStmt = $this->conn->prepare($depSql); - $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId); - $depStmt->execute(); - $depStmt->close(); - } - - // Send Matrix notification for new ticket - NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); - // Redirect to the new ticket header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']); exit; diff --git a/helpers/AccessPolicy.php b/helpers/AccessPolicy.php new file mode 100644 index 0000000..071000b --- /dev/null +++ b/helpers/AccessPolicy.php @@ -0,0 +1,31 @@ + preg_match('/^[a-z0-9_\-]+$/', $g) + ); + + return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS)); + } +} diff --git a/mcp/server.php b/mcp/server.php new file mode 100644 index 0000000..78254c4 --- /dev/null +++ b/mcp/server.php @@ -0,0 +1,159 @@ + 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']); + exit; +} + +$psr17 = new Psr17Factory(); +$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); + +// ServerRequestCreator adds Host both from the URI and from the request +// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which +// the DNS-rebinding check below then rejects. Collapse to the single value the +// client actually sent. +$clientHost = $request->getHeader('Host')[0] ?? ''; +if ($clientHost !== '') { + $request = $request->withHeader('Host', $clientHost); +} + +// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header +// the client controls. The SDK derives the resource_metadata URL in its 401 +// challenge from the request URI, so pin scheme/host/port to the configured +// canonical URL instead of anything the request claims. preserveHost keeps +// the client's real Host header for the DNS-rebinding check below; without +// it withUri() would overwrite Host and make that check a no-op. +$canonical = parse_url($resourceUrl); +$request = $request->withUri( + $request->getUri() + ->withScheme($canonical['scheme']) + ->withHost($canonical['host']) + ->withPort($canonical['port'] ?? null), + true +); + +// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to +// Authelia. Outside the webroot on purpose. +$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache')); + +$validator = new JwtTokenValidator( + issuer: $issuer, + audience: $resourceUrl, + jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache), + // Authelia puts scopes in an `scp` array, not the standard `scope` string + // (verified in #111 phase 1). With the default, every scope check fails. + scopeClaim: 'scp', +); + +$resourcePath = $canonical['path'] ?? ''; +$metadata = new ProtectedResourceMetadata( + authorizationServers: [$issuer], + scopesSupported: ['tickets:read', 'tickets:write'], + resource: $resourceUrl, + resourceName: 'Tinker Tickets', + // RFC 9728 path-suffixed form first (used in the WWW-Authenticate + // challenge), plus the root form some clients probe. + metadataPaths: array_values(array_unique([ + '/.well-known/oauth-protected-resource' . $resourcePath, + '/.well-known/oauth-protected-resource', + ])), +); + +$conn = Database::getConnection(); + +$server = ToolCatalog::register( + Server::builder() + ->setServerInfo('Tinker Tickets', '1.0.0') + // Handshake-era clients (pre-2026-07-28) still use protocol sessions. + ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')), + $conn +)->build(); + +$transport = new StreamableHttpTransport( + $request, + middleware: [ + // CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin + // browser calls are refused). Host allowlist: only the canonical + // hostname, which also refuses direct-by-IP access. + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware([$canonical['host']]), + new ProtectedResourceMetadataMiddleware($metadata), + new AuthorizationMiddleware($validator, $metadata), + // Identity comes from the validated token's server-side request + // attributes, never from client-writable JSON-RPC `_meta` (so the + // SDK's OAuthRequestMetaMiddleware is intentionally not used). + new IdentityMiddleware($conn, $psr17, $psr17), + new ToolScopeMiddleware( + $psr17, + $canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath() + ), + ], +); + +try { + $response = $server->run($transport); +} catch (\Throwable $e) { + error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage()); + $response = $psr17->createResponse(500) + ->withHeader('Content-Type', 'application/json') + ->withBody($psr17->createStream(json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => -32603, 'message' => 'Internal error'], + ]))); +} + +(new SapiEmitter())->emit($response); diff --git a/mcp/src/Auth/IdentityMiddleware.php b/mcp/src/Auth/IdentityMiddleware.php new file mode 100644 index 0000000..c130cdb --- /dev/null +++ b/mcp/src/Auth/IdentityMiddleware.php @@ -0,0 +1,78 @@ +getAttribute('oauth.claims'); + $scopes = $request->getAttribute('oauth.scopes') ?? []; + if (!is_array($claims)) { + // Only reachable if the stack is misordered; never serve anonymously. + return $this->deny(500, 'Identity unavailable'); + } + + $username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : ''; + if ($username === '') { + return $this->deny(403, 'Access token has no preferred_username claim'); + } + + $groupsClaim = $claims['groups'] ?? []; + $groups = implode(',', array_filter( + is_array($groupsClaim) ? $groupsClaim : [$groupsClaim], + 'is_string' + )); + + if (!\AccessPolicy::hasAppAccess($groups)) { + return $this->deny(403, 'Your account is not permitted to use Tinker Tickets'); + } + + $user = (new \UserModel($this->conn))->syncUserFromAuthelia( + $username, + is_string($claims['name'] ?? null) ? $claims['name'] : '', + is_string($claims['email'] ?? null) ? $claims['email'] : '', + $groups + ); + + McpIdentity::set($user, array_values(array_filter($scopes, 'is_string'))); + + return $handler->handle($request); + } + + private function deny(int $status, string $message): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => -32001, 'message' => $message], + ]))); + } +} diff --git a/mcp/src/Auth/McpIdentity.php b/mcp/src/Auth/McpIdentity.php new file mode 100644 index 0000000..fbc30ed --- /dev/null +++ b/mcp/src/Auth/McpIdentity.php @@ -0,0 +1,59 @@ + */ + private static array $scopes = []; + + /** + * @param array $user + * @param list $scopes + */ + public static function set(array $user, array $scopes): void + { + self::$user = $user; + self::$scopes = $scopes; + } + + /** + * @return array + */ + public static function user(): array + { + if (self::$user === null) { + // Unreachable if the middleware stack is intact; fail closed. + throw new \LogicException('MCP identity requested before IdentityMiddleware ran'); + } + return self::$user; + } + + /** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */ + public static function canRead(array $scopes): bool + { + return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true); + } + + public static function canWrite(array $scopes): bool + { + return in_array(self::SCOPE_WRITE, $scopes, true); + } +} diff --git a/mcp/src/Auth/ToolScopeMiddleware.php b/mcp/src/Auth/ToolScopeMiddleware.php new file mode 100644 index 0000000..395a984 --- /dev/null +++ b/mcp/src/Auth/ToolScopeMiddleware.php @@ -0,0 +1,91 @@ +getMethod() !== 'POST') { + return $handler->handle($request); + } + + $payload = json_decode((string)$request->getBody(), true); + $request->getBody()->rewind(); + if (!is_array($payload)) { + return $handler->handle($request); // the SDK answers malformed JSON-RPC itself + } + + $messages = array_is_list($payload) ? $payload : [$payload]; + $needWrite = false; + $needRead = false; + foreach ($messages as $message) { + if (!is_array($message) || !is_string($message['method'] ?? null)) { + continue; // responses to server->client requests carry no method + } + $method = $message['method']; + if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) { + continue; + } + if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) { + $needWrite = true; + } else { + $needRead = true; + } + } + + $scopes = $request->getAttribute('oauth.scopes') ?? []; + if ($needWrite && !McpIdentity::canWrite($scopes)) { + return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.'); + } + if ($needRead && !McpIdentity::canRead($scopes)) { + return $this->insufficient( + $needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ, + 'This operation requires the tickets:read scope.' + ); + } + + return $handler->handle($request); + } + + private function insufficient(string $scope, string $description): ResponseInterface + { + return $this->responseFactory->createResponse(403)->withHeader( + 'WWW-Authenticate', + sprintf( + 'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"', + $scope, + $this->resourceMetadataUrl, + $description + ) + ); + } +} diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php new file mode 100644 index 0000000..41d57e0 --- /dev/null +++ b/mcp/src/ToolCatalog.php @@ -0,0 +1,42 @@ +addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly) + ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly) + ->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive) + ->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive) + ->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update) + ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update); + } +} diff --git a/mcp/src/Tools/TicketReadTools.php b/mcp/src/Tools/TicketReadTools.php new file mode 100644 index 0000000..2a9f5cf --- /dev/null +++ b/mcp/src/Tools/TicketReadTools.php @@ -0,0 +1,183 @@ + + */ + public function searchTickets( + ?string $query = null, + ?string $status = null, + ?int $priority = null, + ?string $category = null, + ?string $assignee = null, + int $page = 1, + int $limit = 20, + ): array { + $user = McpIdentity::user(); + + $allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + if ($status === null || trim($status) === '') { + $statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed')); + } elseif (strtolower(trim($status)) === 'all') { + $statusFilter = null; + } else { + $requested = array_map('trim', explode(',', $status)); + $unknown = array_diff($requested, $allStatuses); + if ($unknown) { + throw new ToolCallException('Unknown status: ' . implode(', ', $unknown) + . '. Valid statuses: ' . implode(', ', $allStatuses)); + } + $statusFilter = implode(',', $requested); + } + + $filters = []; + if ($priority !== null) { + if ($priority < 1 || $priority > 5) { + throw new ToolCallException('priority must be between 1 and 5'); + } + $filters['priority_min'] = $priority; + $filters['priority_max'] = $priority; + } + if ($assignee !== null && trim($assignee) !== '') { + $filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user); + } + + $page = max(1, $page); + $limit = min(50, max(1, $limit)); + + $result = (new \TicketModel($this->conn))->getAllTickets( + $page, + $limit, + $statusFilter, + 'updated_at', + 'desc', + ($category !== null && trim($category) !== '') ? trim($category) : null, + null, + ($query !== null && trim($query) !== '') ? trim($query) : null, + $filters, + $user + ); + + return [ + 'tickets' => array_map([$this, 'summarize'], $result['tickets']), + 'page' => $result['current_page'], + 'pages' => $result['pages'], + 'total' => $result['total'], + ]; + } + + /** + * Get one ticket's full details and its comments. + * + * @param string $ticket_id The ticket ID (digits only, e.g. "123456789"). + * @param bool $include_comments Include the ticket's comments, newest first. + * + * @return array + */ + public function getTicket(string $ticket_id, bool $include_comments = true): array + { + $user = McpIdentity::user(); + $ticketModel = new \TicketModel($this->conn); + + $ticketId = trim($ticket_id); + $ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null; + // Same "not found" for missing and not-visible, so a restricted + // ticket's existence isn't revealed. + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) { + throw new ToolCallException("Ticket {$ticketId} not found"); + } + + $details = $this->summarize($ticket) + [ + 'description' => $ticket['description'] ?? '', + 'visibility_groups' => $ticket['visibility_groups'] ?? null, + 'closed_at' => $ticket['closed_at'] ?? null, + 'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null, + ]; + + if ($include_comments) { + $comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false); + $details['comment_count'] = count($comments); + $details['comments'] = array_map(fn(array $c) => [ + 'comment_id' => (int)$c['comment_id'], + 'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null, + 'created_at' => $c['created_at'] ?? null, + 'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null, + 'text' => $c['comment_text'] ?? '', + ], array_slice($comments, 0, self::MAX_COMMENTS)); + if (count($comments) > self::MAX_COMMENTS) { + $details['comments_truncated'] = true; + } + } + + return $details; + } + + /** + * @param array $user + */ + private function resolveAssigneeFilter(string $assignee, array $user): int|string + { + $lower = strtolower($assignee); + if ($lower === 'me') { + return (int)$user['user_id']; + } + if ($lower === 'unassigned') { + return 'unassigned'; + } + $match = (new \UserModel($this->conn))->getUserByUsername($assignee); + if (!$match) { + throw new ToolCallException("Unknown user: {$assignee}"); + } + return (int)$match['user_id']; + } + + /** + * @param array $t + * + * @return array + */ + private function summarize(array $t): array + { + return [ + 'ticket_id' => (string)$t['ticket_id'], + 'title' => $t['title'] ?? '', + 'status' => $t['status'] ?? null, + 'priority' => isset($t['priority']) ? (int)$t['priority'] : null, + 'category' => $t['category'] ?? null, + 'type' => $t['type'] ?? null, + 'visibility' => $t['visibility'] ?? 'public', + 'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null, + 'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null, + 'created_at' => $t['created_at'] ?? null, + 'updated_at' => $t['updated_at'] ?? null, + 'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']), + ]; + } +} diff --git a/mcp/src/Tools/TicketWriteTools.php b/mcp/src/Tools/TicketWriteTools.php new file mode 100644 index 0000000..2b28012 --- /dev/null +++ b/mcp/src/Tools/TicketWriteTools.php @@ -0,0 +1,216 @@ + + */ + public function createTicket( + string $title, + string $description, + int $priority = 4, + string $category = 'General', + string $type = 'Issue', + string $visibility = 'public', + ?string $visibility_groups = null, + ?string $assignee = null, + ): array { + $user = McpIdentity::user(); + + // The web form constrains these with dropdowns; an API caller can send + // anything, so validate them here before handing off. + if ($priority < 1 || $priority > 5) { + throw new ToolCallException('priority must be between 1 and 5'); + } + if (!in_array($visibility, self::VISIBILITIES, true)) { + throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES)); + } + + $assignedTo = null; + if ($assignee !== null && trim($assignee) !== '') { + $assignedTo = $this->resolveUserId(trim($assignee), $user); + } + + $result = \TicketCreationService::create($this->conn, $user, [ + 'title' => $title, + 'description' => $description, + 'priority' => (string)$priority, + 'category' => $category, + 'type' => $type, + 'status' => 'Open', + 'visibility' => $visibility, + 'visibility_groups' => $visibility_groups, + 'assigned_to' => $assignedTo, + ]); + if (!$result['success']) { + throw new ToolCallException($result['error']); + } + + return [ + 'ticket_id' => (string)$result['ticket_id'], + 'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']), + ]; + } + + /** + * Post a comment on a ticket as you. @username mentions notify that user. + * + * @param string $ticket_id The ticket ID. + * @param string $text The comment text. + * @param bool $markdown Render the comment as markdown. + * @param int|null $reply_to comment_id of the comment you are replying to, if any. + * + * @return array + */ + public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array + { + $user = McpIdentity::user(); + $result = \CommentService::addComment($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'comment_text' => $text, + 'markdown_enabled' => $markdown, + 'parent_comment_id' => $reply_to, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return [ + 'comment_id' => (int)$result['comment_id'], + 'ticket_id' => trim($ticket_id), + 'mentions' => $result['mentions'] ?? [], + ]; + } + + /** + * Change a ticket's status. Transitions follow the Workflow Designer rules; + * some transitions (e.g. closing) require a comment, which is posted as the reason. + * + * @param string $ticket_id The ticket ID. + * @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed". + * @param string|null $comment Reason for the change; required for transitions that need one. + * + * @return array + */ + public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array + { + $user = McpIdentity::user(); + $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + if (!in_array($status, $validStatuses, true)) { + throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses)); + } + + $ticketId = trim($ticket_id); + if (!ctype_digit($ticketId)) { + throw new ToolCallException("Ticket {$ticketId} not found"); + } + + $controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user); + $data = ['status' => $status]; + if ($comment !== null && trim($comment) !== '') { + $data['comment'] = $comment; + $data['markdown_enabled'] = true; + } + $result = $controller->update($ticketId, $data); + if (empty($result['success'])) { + if (!empty($result['requires_comment'])) { + throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.'); + } + throw new ToolCallException($this->errorMessage($result, $ticketId)); + } + + // api/update_ticket.php invalidates the dashboard stats cache after a + // successful update (outside the controller); do the same here. + (new \StatsModel($this->conn))->invalidateCache(); + + return ['ticket_id' => $ticketId, 'status' => $result['status']]; + } + + /** + * Assign a ticket to someone, or unassign it. Only admins, the ticket's + * creator, or its current assignee may do this. + * + * @param string $ticket_id The ticket ID. + * @param string $assignee Username to assign to, "me", or "unassigned". + * + * @return array + */ + public function assignTicket(string $ticket_id, string $assignee): array + { + $user = McpIdentity::user(); + $target = trim($assignee); + $assignedTo = strtolower($target) === 'unassigned' || $target === '' + ? null + : $this->resolveUserId($target, $user); + + $result = \AssignmentService::assign($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'assigned_to' => $assignedTo, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target]; + } + + /** + * @param array $user + */ + private function resolveUserId(string $username, array $user): int + { + if (strtolower($username) === 'me') { + return (int)$user['user_id']; + } + $match = (new \UserModel($this->conn))->getUserByUsername($username); + if (!$match) { + throw new ToolCallException("Unknown user: {$username}"); + } + return (int)$match['user_id']; + } + + /** + * Map a shared-service failure to a tool error. A ticket the user can't + * see reads as "not found" (like get_ticket), never "access denied", so a + * restricted ticket's existence isn't revealed. + * + * @param array $result + */ + private function errorMessage(array $result, string $ticketId): string + { + $error = (string)($result['error'] ?? 'Request failed'); + if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) { + return 'Ticket ' . trim($ticketId) . ' not found'; + } + return $error; + } +} diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index 27686fb..9d68ab8 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -263,21 +263,8 @@ class AuthMiddleware */ private function checkGroupAccess($groups) { - if (empty($groups)) { - return false; - } - - // Check for admin or employee group membership - // Filter to safe characters only to prevent header injection attacks - $userGroups = array_filter( - array_map('trim', explode(',', strtolower($groups))), - function ($g) { - return preg_match('/^[a-z0-9_\-]+$/', $g); - } - ); - $requiredGroups = ['admin', 'employee']; - - return !empty(array_intersect($userGroups, $requiredGroups)); + require_once dirname(__DIR__) . '/helpers/AccessPolicy.php'; + return AccessPolicy::hasAppAccess((string)($groups ?? '')); } /** diff --git a/services/AssignmentService.php b/services/AssignmentService.php new file mode 100644 index 0000000..706fcba --- /dev/null +++ b/services/AssignmentService.php @@ -0,0 +1,98 @@ + false, 'error' => 'Ticket ID required', 'http_status' => 400]; + } + $ticketId = $ticketIdRaw; + + $ticketModel = new TicketModel($conn); + $auditLogModel = new AuditLogModel($conn); + $userModel = new UserModel($conn); + + // Verify ticket exists and user can access it + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + + // Authorization: only admins or the ticket creator/assignee can reassign + if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) { + return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403]; + } + + if ($assignedTo === null || $assignedTo === '') { + // Unassign ticket + $success = $ticketModel->unassignTicket($ticketId, $userId); + if ($success) { + $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); + } + } else { + // Validate assigned_to is a valid user ID + $assignedTo = (int)$assignedTo; + $targetUser = $userModel->getUserById($assignedTo); + if (!$targetUser) { + return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400]; + } + + // Assign ticket + $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); + if ($success) { + $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); + + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) { + $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null; + $assigneeMatrix = isset($targetUser['username']) + ? SynapseHelper::resolveUsername($targetUser['username']) + : null; + NotificationHelper::sendAssignmentNotification( + $ticketId, + $ticket['title'] ?? "Ticket #{$ticketId}", + $assigneeName, + $assigneeMatrix, + $changedByDisplay, + $ticket['visibility'] ?? 'public' + ); + } + } + } + + if (!$success) { + return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500]; + } + + (new StatsModel($conn))->invalidateCache(); + return ['success' => true]; + } +} diff --git a/services/CommentService.php b/services/CommentService.php new file mode 100644 index 0000000..a837ccd --- /dev/null +++ b/services/CommentService.php @@ -0,0 +1,160 @@ + false, 'error' => 'Invalid ticket ID', 'http_status' => 400]; + } + + // Reject empty/whitespace-only comments + $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; + if ($commentTextRaw === '') { + return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400]; + } + + // Persist the trimmed text (not the raw client value) — matches update_comment.php + // and keeps stored comment_text free of leading whitespace that could shift a + // markdown-enabled comment's first line out of column 0 on reload. + $data['comment_text'] = $commentTextRaw; + + // Never trust a client-supplied display name — always attribute the comment to + // the authenticated user. + $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; + + // Verify user can access the ticket before allowing a comment + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + return ['success' => false, 'error' => 'Access denied', 'http_status' => 403]; + } + + // Initialize models + $commentModel = new CommentModel($conn); + $auditLog = new AuditLogModel($conn); + + // If replying, the parent comment must belong to this same (accessible) ticket. + if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') { + $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']); + if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) { + return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400]; + } + } + + // Extract @mentions from comment text + $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); + $mentionedUsers = []; + if (!empty($mentions)) { + $mentionedUsers = $commentModel->getMentionedUsers($mentions); + } + + // Add comment with user tracking + $result = $commentModel->addComment($ticketId, $data, $userId); + + // Log comment creation to audit log + if ($result['success'] && isset($result['comment_id'])) { + $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); + + // Log mentions to audit log + foreach ($mentionedUsers as $mentionedUser) { + $auditLog->log( + $userId, + 'mention', + 'user', + (string)$mentionedUser['user_id'], + [ + 'ticket_id' => $ticketId, + 'comment_id' => $result['comment_id'], + 'mentioned_username' => $mentionedUser['username'] + ] + ); + } + + // Matrix notifications + $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $commentText = $data['comment_text'] ?? ''; + $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; + $ticketVisibility = $ticket['visibility'] ?? 'public'; + + // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API. + // Only notify mentioned users who actually have access to this ticket; + // otherwise a mention would DM them the ticket's title and comment text + // even though canUserAccessTicket() would deny them the ticket itself. + $accessibleMentionedUsers = array_filter( + $mentionedUsers, + fn($u) => $ticketModel->canUserAccessTicket($ticket, $u) + ); + if (!empty($accessibleMentionedUsers)) { + $mentionedUsernames = array_column($accessibleMentionedUsers, 'username'); + $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); + if (!empty($mentionedMatrixIds)) { + NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); + } + } + + // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { + NotificationHelper::sendCommentNotification( + $ticketId, + $ticketTitle, + $commentText, + $authorDisplay, + $ticketVisibility !== 'public', + $ticketVisibility + ); + } + + // Notify watchers of the new comment + NotificationHelper::notifyWatchers( + $conn, + $ticketId, + $ticketTitle, + 'comment_added', + ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], + (int)$userId, + $ticketVisibility + ); + + // Add mentioned users to result for frontend + $result['mentions'] = array_map(function ($u) { + return $u['username']; + }, $mentionedUsers); + } + + // Add user info to result for frontend avatar rendering + if ($result['success']) { + $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; + $result['user_id'] = $userId; + } + + return $result; + } +} diff --git a/services/TicketCreationService.php b/services/TicketCreationService.php new file mode 100644 index 0000000..f60138b --- /dev/null +++ b/services/TicketCreationService.php @@ -0,0 +1,134 @@ + value), link_duplicate_of + * + * @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...] + */ + public static function create(mysqli $conn, array $currentUser, array $input): array + { + $userId = $currentUser['user_id'] ?? null; + + // Handle visibility groups (a list from the web form's checkboxes, or a string) + $visibilityGroups = null; + if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) { + $visibilityGroups = implode(',', array_map('trim', $input['visibility_groups'])); + } elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') { + $visibilityGroups = trim($input['visibility_groups']); + } + + // Honor the posted status, validated against the app's canonical list + $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + $status = $input['status'] ?? 'Open'; + if (!in_array($status, $validStatuses, true)) { + $status = 'Open'; + } + + $ticketData = [ + 'title' => trim($input['title'] ?? ''), + 'description' => $input['description'] ?? '', + 'priority' => $input['priority'] ?? '4', + 'category' => $input['category'] ?? 'General', + 'type' => $input['type'] ?? 'Issue', + 'status' => $status, + 'visibility' => $input['visibility'] ?? 'public', + 'visibility_groups' => $visibilityGroups, + 'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null + ]; + + if ($ticketData['title'] === '') { + return ['success' => false, 'error' => 'Title is required']; + } + + if (trim($ticketData['description']) === '') { + return ['success' => false, 'error' => 'Description is required']; + } + + // Custom fields applicable to the submitted category — validate + // is_required server-side (the form is novalidate, and a field + // hidden by the client-side category toggle must not silently + // bypass a requirement that applies to the category actually + // submitted). + $customFieldModel = new CustomFieldModel($conn); + $allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true); + $submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : []; + $applicableFieldDefs = array_filter( + $allCustomFieldDefs, + fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] + ); + $customFieldsToSave = []; + foreach ($applicableFieldDefs as $def) { + $fieldId = (int)$def['field_id']; + $raw = $submittedCustomFields[$fieldId] ?? null; + $normalized = $def['field_type'] === 'checkbox' + ? (!empty($raw) ? '1' : '0') + : (is_scalar($raw) ? trim((string)$raw) : ''); + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + return ['success' => false, 'error' => $def['field_label'] . ' is required']; + } + + if ($normalized !== '') { + $customFieldsToSave[$fieldId] = $normalized; + } + } + + // Create ticket with user tracking + $result = (new TicketModel($conn))->createTicket($ticketData, $userId); + if (!$result['success']) { + return ['success' => false, 'error' => $result['error']]; + } + + // Log ticket creation to audit log + if ($userId) { + (new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData); + } + + // Ticket counts changed — invalidate the cached dashboard stats + (new StatsModel($conn))->invalidateCache(); + + // Persist custom field values for the fields applicable to this ticket's category + if (!empty($customFieldsToSave)) { + $customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); + } + + // Auto-link as duplicate if requested + $linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? '')); + if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { + $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by) + VALUES (?, ?, 'duplicates', ?)"; + $depStmt = $conn->prepare($depSql); + $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId); + $depStmt->execute(); + $depStmt->close(); + } + + // Send Matrix notification for new ticket + NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); + + return ['success' => true, 'ticket_id' => $result['ticket_id']]; + } +}