Verified, high-confidence fixes from a project-wide review: - markdown.js: escape " and ' in the HTML-escape step. User-controlled image/link URLs and alt text were interpolated into "..." attributes without quote escaping, allowing attribute breakout and injected event handlers (stored XSS, only mitigated by CSP). Flagged independently by two reviewers. - cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a mangled crontab example inside the docblock contained */ which closed the comment early, causing a fatal parse error — both cron jobs never ran. Rewrote the docblocks without a literal */. - update_ticket.php: validate visibility BEFORE the core DB write so an invalid payload can't leave the ticket updated while the request reports failure (which also skipped the audit delta and stats cache invalidation). - watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6 list); use an unbounded COUNT(*) so it matches the POST path. - notifications.php: "assigned to me" LIKE pattern lacked a trailing delimiter, so user 12 also matched 120/123/etc.; anchor with }. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<?php
|
|
|
|
// Enable error reporting for debugging
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0); // Don't display errors in the response
|
|
|
|
// Apply rate limiting
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Start output buffering to capture any errors
|
|
ob_start();
|
|
|
|
try {
|
|
// Load config
|
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
|
require_once $configPath;
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
|
|
// Load models directly with absolute paths
|
|
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
|
|
|
require_once $ticketModelPath;
|
|
require_once $commentModelPath;
|
|
require_once $auditLogModelPath;
|
|
require_once $workflowModelPath;
|
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
|
|
|
// Check authentication via session
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
throw new Exception("Authentication required");
|
|
}
|
|
|
|
// CSRF Protection
|
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
$currentUser = $_SESSION['user'];
|
|
$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'
|
|
];
|
|
}
|
|
|
|
// Validate priority range
|
|
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Priority must be between 1 and 5'
|
|
];
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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'
|
|
];
|
|
}
|
|
}
|
|
|
|
// 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']
|
|
];
|
|
}
|
|
}
|
|
|
|
// Update ticket with user tracking and optional optimistic locking
|
|
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
|
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
|
|
|
// Handle conflict case
|
|
if (!$result['success']) {
|
|
$response = [
|
|
'success' => false,
|
|
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
|
];
|
|
if (!empty($result['conflict'])) {
|
|
$response['conflict'] = true;
|
|
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
|
}
|
|
return $response;
|
|
}
|
|
|
|
// Handle visibility update if provided (already validated above)
|
|
if (isset($data['visibility'])) {
|
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
|
if ($visResult && $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
|
|
);
|
|
NotificationHelper::notifyWatchers(
|
|
$this->conn,
|
|
$id,
|
|
$updateData['title'],
|
|
'status_changed',
|
|
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
|
(int)$this->userId
|
|
);
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'status' => $updateData['status'],
|
|
'priority' => $updateData['priority'],
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
'message' => 'Ticket updated successfully'
|
|
];
|
|
}
|
|
}
|
|
|
|
// Use centralized database connection
|
|
$conn = Database::getConnection();
|
|
|
|
// Check request method
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
|
}
|
|
|
|
// Get POST data
|
|
$input = file_get_contents('php://input');
|
|
$data = json_decode($input, true);
|
|
|
|
if (!$data) {
|
|
throw new Exception("Invalid JSON data received: " . $input);
|
|
}
|
|
|
|
if (!isset($data['ticket_id'])) {
|
|
throw new Exception("Missing ticket_id parameter");
|
|
}
|
|
|
|
$ticketId = trim((string)$data['ticket_id']);
|
|
|
|
// Initialize controller
|
|
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
|
|
|
// Update ticket
|
|
$result = $controller->update($ticketId, $data);
|
|
|
|
// Discard any output that might have been generated
|
|
ob_end_clean();
|
|
|
|
// Invalidate stats cache on successful ticket update
|
|
if (!empty($result['success'])) {
|
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
|
(new StatsModel($conn))->invalidateCache();
|
|
}
|
|
|
|
// Return response
|
|
if (!empty($result['http_status'])) {
|
|
http_response_code($result['http_status']);
|
|
unset($result['http_status']);
|
|
}
|
|
header('Content-Type: application/json');
|
|
echo json_encode($result);
|
|
} catch (Exception $e) {
|
|
// Discard any output that might have been generated
|
|
ob_end_clean();
|
|
|
|
// Log error details but don't expose to client
|
|
error_log("Update ticket API error: " . $e->getMessage());
|
|
|
|
// Return error response
|
|
header('Content-Type: application/json');
|
|
http_response_code(500);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'An internal error occurred'
|
|
]);
|
|
}
|