134b9b3693
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Security: - Add return after all jsonForbidden() calls (13 methods) to prevent ACL bypass if $app->close() fails to terminate - Add throw after requireAuth() in REST API controller (same pattern) - Add path traversal guard to AttachmentService::getAbsolutePath() using realpath + prefix check Error handling: - Log install notification email failures instead of empty catch - Log DB errors in getUserEmail(), getNotificationConfig(), getComponentConfig() instead of silent fallbacks - Log PHP upload error codes in AttachmentService - Check Folder::create() return value before upload loop - Fix searchKb() missing return on short query + log DB errors - Fix ntfy push to capture curl_error() on connection failure - Upgrade AutomationEngine inner catch to LOG_ERROR with rule ID
184 lines
5.0 KiB
PHP
184 lines
5.0 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoSuiteClient
|
|
* @subpackage com_mokosuiteclient
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Filesystem\File;
|
|
use Joomla\CMS\Filesystem\Folder;
|
|
use Joomla\CMS\Log\Log;
|
|
|
|
class AttachmentService
|
|
{
|
|
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
|
|
|
|
private const ALLOWED_EXTENSIONS = [
|
|
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
|
|
'zip', 'gz', 'tar',
|
|
];
|
|
|
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
|
|
/**
|
|
* Upload file(s) for a ticket or reply.
|
|
*
|
|
* @param int $ticketId Ticket ID
|
|
* @param int|null $replyId Reply ID (null for ticket-level attachments)
|
|
* @param array $files $_FILES array entry (single or multi)
|
|
* @return array Saved attachment records
|
|
*/
|
|
public static function upload(int $ticketId, ?int $replyId, array $files): array
|
|
{
|
|
$saved = [];
|
|
|
|
// Normalize single file to array format
|
|
if (!is_array($files['name'])) {
|
|
$files = [
|
|
'name' => [$files['name']],
|
|
'type' => [$files['type']],
|
|
'tmp_name' => [$files['tmp_name']],
|
|
'error' => [$files['error']],
|
|
'size' => [$files['size']],
|
|
];
|
|
}
|
|
|
|
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
|
|
|
|
if (!is_dir($ticketDir) && !Folder::create($ticketDir)) {
|
|
Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient');
|
|
return [];
|
|
}
|
|
|
|
$userId = (int) Factory::getUser()->id;
|
|
$db = Factory::getDbo();
|
|
|
|
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
|
|
{
|
|
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
|
Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient');
|
|
continue;
|
|
}
|
|
|
|
$originalName = File::makeSafe($files['name'][$i]);
|
|
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
|
|
|
// Validate extension
|
|
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
|
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
|
|
continue;
|
|
}
|
|
|
|
// Validate size
|
|
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
|
|
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
|
|
continue;
|
|
}
|
|
|
|
// Generate unique filename to prevent overwrites
|
|
$storedName = uniqid('att_', true) . '.' . $ext;
|
|
$destPath = $ticketDir . '/' . $storedName;
|
|
|
|
if (!File::upload($files['tmp_name'][$i], $destPath)) {
|
|
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
|
|
continue;
|
|
}
|
|
|
|
$record = (object) [
|
|
'ticket_id' => $ticketId,
|
|
'reply_id' => $replyId,
|
|
'filename' => $originalName,
|
|
'filepath' => $ticketId . '/' . $storedName,
|
|
'filesize' => $files['size'][$i],
|
|
'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream',
|
|
'uploaded_by' => $userId,
|
|
'created' => Factory::getDate()->toSql(),
|
|
];
|
|
|
|
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
|
|
$saved[] = $record;
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Get attachments for a ticket.
|
|
*/
|
|
public static function getForTicket(int $ticketId): array
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('a.*, u.name AS uploader_name')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
|
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
|
|
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
|
|
->order('a.created ASC')
|
|
);
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get the absolute filesystem path for an attachment.
|
|
*/
|
|
public static function getAbsolutePath(object $attachment): ?string
|
|
{
|
|
$path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath);
|
|
if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) {
|
|
return null;
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Delete an attachment (file + DB record).
|
|
*/
|
|
public static function delete(int $attachmentId): bool
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from('#__mokosuiteclient_ticket_attachments')
|
|
->where('id = ' . $attachmentId)
|
|
);
|
|
$att = $db->loadObject();
|
|
|
|
if (!$att) {
|
|
return false;
|
|
}
|
|
|
|
$path = self::STORAGE_DIR . '/' . $att->filepath;
|
|
|
|
if (file_exists($path)) {
|
|
File::delete($path);
|
|
}
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->delete('#__mokosuiteclient_ticket_attachments')
|
|
->where('id = ' . $attachmentId)
|
|
)->execute();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Format file size for display.
|
|
*/
|
|
public static function formatSize(int $bytes): string
|
|
{
|
|
if ($bytes < 1024) return $bytes . ' B';
|
|
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
|
return round($bytes / 1048576, 1) . ' MB';
|
|
}
|
|
}
|