193 lines
6.9 KiB
PHP
Executable File
193 lines
6.9 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\Helpdesk\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Validation\Rule;
|
|
use Modules\Helpdesk\Models\CannedResponse;
|
|
use Modules\Helpdesk\Models\Ticket;
|
|
use Modules\Helpdesk\Services\CannedResponseRenderer;
|
|
use Modules\Helpdesk\Services\TicketService;
|
|
|
|
final class TicketCommentStoreController extends Controller
|
|
{
|
|
/**
|
|
* Store a ticket conversation comment or nested reply using a normal HTTP POST.
|
|
*
|
|
* This keeps the conversation workflow reliable when rich-editor, Flux, or
|
|
* Livewire JavaScript event handling does not invoke the Livewire method.
|
|
*/
|
|
public function __invoke(Request $request, Ticket $ticket, TicketService $tickets, CannedResponseRenderer $renderer): RedirectResponse
|
|
{
|
|
Gate::authorize('comment', $ticket);
|
|
|
|
$validated = $request->validate([
|
|
'subject' => ['nullable', 'string', 'max:180'],
|
|
'commentBody' => ['nullable', 'string'],
|
|
'internal' => ['nullable', 'boolean'],
|
|
'parent_id' => [
|
|
'nullable',
|
|
'integer',
|
|
Rule::exists('helpdesk_comments', 'id')->where('ticket_id', $ticket->id),
|
|
],
|
|
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
|
|
'attachments' => ['nullable', 'array'],
|
|
'attachments.*' => $this->attachmentFileRules(),
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
abort_if($user === null, 403);
|
|
|
|
$body = trim((string) ($validated['commentBody'] ?? ''));
|
|
$subject = trim((string) ($validated['subject'] ?? ''));
|
|
|
|
if (filled($validated['canned_response_id'] ?? null)) {
|
|
$response = CannedResponse::query()
|
|
->where('is_active', true)
|
|
->findOrFail((int) $validated['canned_response_id']);
|
|
|
|
if ($body === '') {
|
|
$body = trim($renderer->render($response->body, $ticket, $user));
|
|
}
|
|
|
|
if ($subject === '') {
|
|
$subject = (string) $response->name;
|
|
}
|
|
}
|
|
|
|
$body = $this->normaliseHtmlBody($body);
|
|
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
|
|
|
|
validator(['commentBody' => $body], [
|
|
'commentBody' => ['required', 'string', 'min:2'],
|
|
])->validate();
|
|
|
|
$uploadedFiles = $request->file('attachments', []);
|
|
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
|
|
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid()));
|
|
|
|
$tickets->comment(
|
|
ticket: $ticket,
|
|
user: $user,
|
|
body: $body,
|
|
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
|
|
files: $uploadedFiles,
|
|
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
|
|
subject: $subject,
|
|
);
|
|
|
|
return redirect()
|
|
->route('helpdesk.tickets.show', $ticket)
|
|
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
|
|
}
|
|
|
|
/**
|
|
* Derive a user-friendly Lotus Notes style response subject.
|
|
*/
|
|
private function normaliseSubject(string $subject, string $body, bool $isReply): string
|
|
{
|
|
$subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
|
|
|
if ($subject !== '') {
|
|
return Str::limit($subject, 180, '');
|
|
}
|
|
|
|
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: '');
|
|
|
|
if ($plain === '') {
|
|
return $isReply ? 'RE: Response' : 'Response';
|
|
}
|
|
|
|
return ($isReply ? 'RE: ' : '').Str::limit($plain, 120, '');
|
|
}
|
|
|
|
/**
|
|
* Keep supported formatting tags but strip scripts and unsafe inline handlers.
|
|
*/
|
|
private function normaliseHtmlBody(string $body): string
|
|
{
|
|
$body = trim($body);
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
if (! str_contains($body, '<') && ! str_contains($body, '>') && ! str_contains($body, '&lt;')) {
|
|
break;
|
|
}
|
|
|
|
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
if ($decoded === $body) {
|
|
break;
|
|
}
|
|
|
|
$body = $decoded;
|
|
}
|
|
|
|
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
|
|
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
|
|
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
|
|
$body = trim(strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'));
|
|
|
|
if ($body === '') {
|
|
return '';
|
|
}
|
|
|
|
if ($body === strip_tags($body)) {
|
|
return collect(preg_split('/\R{2,}/', $body) ?: [])
|
|
->map(static fn (string $paragraph): string => trim($paragraph))
|
|
->filter(static fn (string $paragraph): bool => $paragraph !== '')
|
|
->map(static fn (string $paragraph): string => '<p>'.nl2br(e($paragraph), false).'</p>')
|
|
->implode('');
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
/** Internal notes are restricted to Thredary administrators and helpdesk agents. */
|
|
private function userCanCreateInternalNote(mixed $user): bool
|
|
{
|
|
if ($user === null) {
|
|
return false;
|
|
}
|
|
|
|
return $user->isHelpdeskAgent();
|
|
}
|
|
|
|
/**
|
|
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
|
*
|
|
* @param bool $required Whether at least one uploaded file is required.
|
|
* @return array<int, string>
|
|
*/
|
|
private function attachmentFileRules(bool $required = false): array
|
|
{
|
|
return array_values(array_filter([
|
|
$required ? 'required' : null,
|
|
'file',
|
|
'max:'.(int) config('helpdesk.max_upload_kb', 10240),
|
|
'extensions:'.implode(',', $this->allowedAttachmentExtensions()),
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* Return lower-case Helpdesk attachment extensions.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function allowedAttachmentExtensions(): array
|
|
{
|
|
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
|
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
|
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|