CSS fixes: - Fix [ ] brackets appearing below button text by replacing display:inline-flex with display:inline-block + white-space:nowrap on .btn — removes cross-browser flex pseudo-element inconsistency as root cause - Remove conflicting .btn::before ripple block (position:absolute was overriding bracket content positioning) - Remove overflow:hidden from .btn which was clipping bracket content - Fix body::after duplicate rule causing GPU layer blink (second position:fixed rule re-created compositor layer, overriding display:none suppression) - Replace all transition:all with scoped property transitions in dashboard.css, ticket.css, base.css (prevents full CSS property evaluation on every hover) - Convert pulse-warning/pulse-critical keyframes from box-shadow to opacity animation (GPU-composited, eliminates CPU repaints at 60fps) - Fix mobile *::before/*::after blanket content:none rule — now targets only decorative frame glyphs, preserving button brackets and status indicators - Remove --terminal-green-dim override that broke .lt-btn hover backgrounds JS fixes: - Fix all lt.lt.toast.* double-prefix instances in dashboard.js - Add null guard before .appendChild() on bulkAssignUser select - Replace all remaining emoji with terminal bracket notation (dashboard.js, ticket.js, markdown.js) - Migrate all toast.*() shim calls to lt.toast.* across all JS files View fixes: - Remove hardcoded [ ] brackets from .btn buttons (CSS now adds them) - Replace all emoji with terminal bracket notation in all views and admin views - Add missing CSP nonces to AuditLogView.php and UserActivityView.php script tags - Bump CSS version strings to ?v=20260319b for cache busting Security fixes: - update_ticket.php: add authorization check (non-admins can only edit their own or assigned tickets) - add_comment.php: validate and cast ticket_id to integer with 400 response - clone_ticket.php: fix unconditional session_start(), add ticket ID validation, add internal ticket access check - bulk_operation.php: add HTTP 401/403 status codes on auth failures - upload_attachment.php: fix missing $conn arg in AttachmentModel constructor - assign_ticket.php: add ticket existence check and permission verification Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
208 lines
6.6 KiB
PHP
208 lines
6.6 KiB
PHP
<?php
|
|
/**
|
|
* Upload Attachment API
|
|
*
|
|
* Handles file uploads for ticket attachments
|
|
*/
|
|
|
|
// Capture errors for debugging
|
|
ini_set('display_errors', 0);
|
|
error_reporting(E_ALL);
|
|
|
|
// Apply rate limiting (also starts session)
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Ensure session is started
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
// Check authentication
|
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
ResponseHelper::unauthorized();
|
|
}
|
|
|
|
// Handle GET requests to list attachments
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$ticketId = $_GET['ticket_id'] ?? '';
|
|
|
|
if (empty($ticketId)) {
|
|
ResponseHelper::error('Ticket ID is required');
|
|
}
|
|
|
|
// Validate ticket ID format (9-digit number)
|
|
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
|
ResponseHelper::error('Invalid ticket ID format');
|
|
}
|
|
|
|
try {
|
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
|
|
|
// Add formatted file size and icon to each attachment
|
|
foreach ($attachments as &$att) {
|
|
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
|
}
|
|
|
|
ResponseHelper::success(['attachments' => $attachments]);
|
|
} catch (Exception $e) {
|
|
ResponseHelper::serverError('Failed to load attachments');
|
|
}
|
|
}
|
|
|
|
// Only accept POST requests for uploads
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
ResponseHelper::error('Method not allowed', 405);
|
|
}
|
|
|
|
// Verify CSRF token
|
|
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
ResponseHelper::forbidden('Invalid CSRF token');
|
|
}
|
|
|
|
// Get ticket ID
|
|
$ticketId = $_POST['ticket_id'] ?? '';
|
|
if (empty($ticketId)) {
|
|
ResponseHelper::error('Ticket ID is required');
|
|
}
|
|
|
|
// Validate ticket ID format (9-digit number)
|
|
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
|
ResponseHelper::error('Invalid ticket ID format');
|
|
}
|
|
|
|
// Check if file was uploaded
|
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
|
ResponseHelper::error('No file uploaded');
|
|
}
|
|
|
|
$file = $_FILES['file'];
|
|
|
|
// Check for upload errors
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
$errorMessages = [
|
|
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
|
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
|
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
|
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
|
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
|
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
|
|
];
|
|
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
|
|
ResponseHelper::error($message);
|
|
}
|
|
|
|
// Check file size
|
|
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
|
|
if ($file['size'] > $maxSize) {
|
|
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
|
}
|
|
|
|
// Get MIME type
|
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
$mimeType = $finfo->file($file['tmp_name']);
|
|
|
|
// Validate file type
|
|
if (!AttachmentModel::isAllowedType($mimeType)) {
|
|
ResponseHelper::error('File type not allowed: ' . $mimeType);
|
|
}
|
|
|
|
// Create upload directory if it doesn't exist
|
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
|
if (!is_dir($uploadDir)) {
|
|
if (!mkdir($uploadDir, 0755, true)) {
|
|
ResponseHelper::serverError('Failed to create upload directory');
|
|
}
|
|
}
|
|
|
|
// Create ticket subdirectory
|
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
|
if (!is_dir($ticketDir)) {
|
|
if (!mkdir($ticketDir, 0755, true)) {
|
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
|
}
|
|
}
|
|
|
|
// Generate unique filename
|
|
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
|
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
|
|
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
|
|
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
|
|
|
// Move uploaded file
|
|
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|
ResponseHelper::serverError('Failed to move uploaded file');
|
|
}
|
|
|
|
// Sanitize original filename
|
|
$originalFilename = basename($file['name']);
|
|
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
|
if (empty($originalFilename)) {
|
|
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
|
|
}
|
|
|
|
// Save to database
|
|
try {
|
|
$attachmentModel = new AttachmentModel($conn);
|
|
$attachmentId = $attachmentModel->addAttachment(
|
|
$ticketId,
|
|
$uniqueFilename,
|
|
$originalFilename,
|
|
$file['size'],
|
|
$mimeType,
|
|
$_SESSION['user']['user_id']
|
|
);
|
|
|
|
if (!$attachmentId) {
|
|
// Clean up file if database insert fails
|
|
unlink($targetPath);
|
|
ResponseHelper::serverError('Failed to save attachment record');
|
|
}
|
|
|
|
// Log the upload
|
|
$conn = Database::getConnection();
|
|
$auditLog = new AuditLogModel($conn);
|
|
$auditLog->log(
|
|
$_SESSION['user']['user_id'],
|
|
'attachment_upload',
|
|
'ticket_attachments',
|
|
(string)$attachmentId,
|
|
[
|
|
'ticket_id' => $ticketId,
|
|
'filename' => $originalFilename,
|
|
'size' => $file['size'],
|
|
'mime_type' => $mimeType
|
|
]
|
|
);
|
|
|
|
ResponseHelper::created([
|
|
'attachment_id' => $attachmentId,
|
|
'filename' => $originalFilename,
|
|
'file_size' => $file['size'],
|
|
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
|
'mime_type' => $mimeType,
|
|
'icon' => AttachmentModel::getFileIcon($mimeType),
|
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
|
'uploaded_at' => date('Y-m-d H:i:s')
|
|
], 'File uploaded successfully');
|
|
|
|
} catch (Exception $e) {
|
|
// Clean up file on error
|
|
if (file_exists($targetPath)) {
|
|
unlink($targetPath);
|
|
}
|
|
ResponseHelper::serverError('Failed to process attachment');
|
|
}
|