Files
tinker_tickets/api/upload_attachment.php
T
jaredandClaude Sonnet 5 71bf64c1e2 Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.

Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.

Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.

For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.

Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:15 -04:00

343 lines
12 KiB
PHP

<?php
/**
* Upload Attachment API
*
* Handles file uploads for ticket attachments
*/
// Capture errors for debugging
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// 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__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
/**
* Strip EXIF/metadata (including GPS) from an image file in place by
* decoding and re-encoding it via GD, which drops metadata chunks that
* aren't part of the pixel data. Best-effort: leaves the file untouched on
* any failure (corrupt image, unsupported format, GD unavailable) rather
* than blocking the upload — original bytes are what would have been stored
* anyway before this existed.
*
* download_attachment.php streams attachments back byte-for-byte to any user
* with ticket visibility, so an unstripped phone photo's embedded GPS data
* would otherwise leak a data center/office's physical location even on a
* Confidential-visibility ticket.
*/
function stripImageMetadata(string $path, string $mimeType): void
{
if (!extension_loaded('gd')) {
return;
}
// Guard against a decompression-bomb-style crafted image (small file,
// huge decoded pixel buffer) exhausting memory during decode.
$dims = @getimagesize($path);
if ($dims === false) {
return;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return;
}
$image = @$loader($path);
if ($image === false) {
return;
}
// Preserve transparency for formats that support it.
imagesavealpha($image, true);
imagealphablending($image, false);
$tmpPath = $path . '.tmp';
$saved = match ($mimeType) {
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
'image/png' => imagepng($image, $tmpPath, 6),
'image/gif' => imagegif($image, $tmpPath),
'image/webp' => imagewebp($image, $tmpPath, 90),
default => false,
};
imagedestroy($image);
if ($saved && file_exists($tmpPath)) {
rename($tmpPath, $path);
} elseif (file_exists($tmpPath)) {
unlink($tmpPath);
}
}
// 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 (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
try {
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
$attachmentModel = new AttachmentModel($conn);
$total = $attachmentModel->getAttachmentCount($ticketId);
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
// 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,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
} 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::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get ticket ID
$ticketId = $_POST['ticket_id'] ?? '';
if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
// Verify user can access the ticket before accepting upload
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
// 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) . ')');
}
// Check per-ticket attachment count/storage quota — bounds an authenticated
// low-privilege user slowly filling the uploads/ disk across many tickets,
// which was previously bounded only by the request-rate limiter, not volume.
$attachmentModel = new AttachmentModel($conn);
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
}
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
ResponseHelper::error(
'This upload would exceed the ticket\'s total attachment size limit of '
. AttachmentModel::formatFileSize($maxTotalSize)
);
}
// 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 — ticketId is validated as digits-only above
$ticketDir = $uploadDir . '/' . $ticketId;
if (!is_dir($ticketDir)) {
if (!mkdir($ticketDir, 0755, true)) {
ResponseHelper::serverError('Failed to create ticket upload directory');
}
}
// Confirm resolved path stays within the upload root (defence-in-depth)
$resolvedTicketDir = realpath($ticketDir);
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
ResponseHelper::error('Invalid upload path');
}
// Derive extension from validated MIME type (never from user-supplied filename)
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
$mimeToExt = [
'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
];
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
$targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Strip EXIF/GPS metadata from image uploads before it's ever served back
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
}
// Sanitize original filename
$originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
if (empty($originalFilename)) {
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
}
// Save to database
try {
$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');
}