From c44b362710f65b5c4b12658a1f75979936b97470 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 24 Jun 2026 10:40:19 +0100 Subject: [PATCH] new ataxchments preview code --- Modules/Helpdesk/Config/config.php | 9 + ...elpdesk_attachment_experience_metadata.php | 64 ++++++ .../AttachmentDeleteController.php | 36 +++ .../AttachmentDownloadController.php | 148 ++++++++++++ .../AttachmentPreviewController.php | 50 ++++ Modules/Helpdesk/Models/Attachment.php | 215 +++++++++++++++++- .../attachments/source-preview.blade.php | 60 +++++ .../views/components/html-editor.blade.php | 21 +- .../views/livewire/ticket-create.blade.php | 103 ++++++++- .../views/livewire/ticket-show.blade.php | 82 +++++-- Modules/Helpdesk/Routes/web.php | 6 + .../Helpdesk/Services/AttachmentService.php | 105 ++++++++- .../TicketAttachmentExperienceFilesTest.php | 68 ++++++ .../TicketCreateAttachmentPersistenceTest.php | 43 ++++ .../TicketSourceAttachmentFilesTest.php | 50 ++++ Modules/Helpdesk/changelog.yaml | 52 +++++ Modules/Helpdesk/module.json | 2 +- 17 files changed, 1071 insertions(+), 43 deletions(-) create mode 100644 Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php create mode 100644 Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php create mode 100644 Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php create mode 100644 Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketAttachmentExperienceFilesTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPersistenceTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketSourceAttachmentFilesTest.php diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index c5922cfa..d09cb6b7 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -15,6 +15,15 @@ return [ 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', ], + 'allowed_source_extensions' => [ + 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', + 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md', + ], + 'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'], + 'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null), + // Backwards-compatible alias for older module code/tests that still read this key. 'allowed_mimes' => [], 'default_status_slug' => 'new', diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php new file mode 100644 index 00000000..4f549bc3 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php @@ -0,0 +1,64 @@ +string('extension', 32)->nullable()->after('original_name')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'category')) { + $table->string('category', 32)->nullable()->after('extension')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'checksum_sha256')) { + $table->string('checksum_sha256', 64)->nullable()->after('size')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'download_count')) { + $table->unsignedInteger('download_count')->default(0)->after('checksum_sha256'); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) { + $table->timestamp('last_downloaded_at')->nullable()->after('download_count'); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { + $table->timestamp('deleted_at')->nullable()->after('last_downloaded_at')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { + $table->foreignId('deleted_by')->nullable()->after('deleted_at')->constrained('users')->nullOnDelete(); + } + }); + } + + /** + * Remove attachment experience metadata. + */ + public function down(): void + { + Schema::table('helpdesk_attachments', static function (Blueprint $table): void { + if (Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { + $table->dropConstrainedForeignId('deleted_by'); + } + + foreach (['deleted_at', 'last_downloaded_at', 'download_count', 'checksum_sha256', 'category', 'extension'] as $column) { + if (Schema::hasColumn('helpdesk_attachments', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php new file mode 100644 index 00000000..c7553420 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php @@ -0,0 +1,36 @@ +ticket; + abort_if($ticket === null, 404); + + Gate::authorize('update', $ticket); + + if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { + $attachments->softDelete($attachment, (int) auth()->id()); + } else { + $attachments->delete($attachment); + } + + return redirect() + ->route('helpdesk.tickets.show', $ticket) + ->with('ticket-attachments-deleted', 'Attachment removed.'); + } +} diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php index 37446b50..b21cb4d6 100644 --- a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Http\Controllers; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\RedirectResponse; +use Illuminate\View\View; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -49,6 +50,153 @@ final class AttachmentDownloadController extends Controller abort_if($path === '', 404); abort_unless(Storage::disk($disk)->exists($path), 404); + if (Schema::hasColumn('helpdesk_attachments', 'download_count') && Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) { + DB::table('helpdesk_attachments') + ->where('id', $attachment) + ->update([ + 'download_count' => DB::raw('download_count + 1'), + 'last_downloaded_at' => now(), + ]); + } + return Storage::disk($disk)->download($path, $name); } + + /** + * Preview a safe Helpdesk attachment using this existing controller so optimized + * Composer autoload does not need a newly introduced route action class. + * + * @throws FileNotFoundException + */ + public function preview(int $attachment): View|StreamedResponse|RedirectResponse + { + abort_unless(Schema::hasTable('helpdesk_attachments'), 404); + + $row = DB::table('helpdesk_attachments')->where('id', $attachment)->first(); + + abort_if($row === null, 404); + + $ticketId = $row->ticket_id ?? null; + + if ($ticketId !== null) { + $ticket = Ticket::query()->findOrFail((int) $ticketId); + Gate::authorize('view', $ticket); + } + + $disk = (string) ($row->disk ?? 'public'); + $path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? ''); + $name = (string) ($row->original_name + ?? $row->original_filename + ?? $row->file_name + ?? $row->filename + ?? basename($path)); + $mimeType = (string) ($row->mime_type ?? 'application/octet-stream'); + $extension = strtolower((string) (($row->extension ?? null) ?: pathinfo($name, PATHINFO_EXTENSION))); + + abort_if($path === '', 404); + abort_unless(Storage::disk($disk)->exists($path), 404); + + if ($this->isSourceExtension($extension)) { + $contents = Storage::disk($disk)->get($path); + + return view('helpdesk::attachments.source-preview', [ + 'attachment' => $row, + 'contents' => is_string($contents) ? $contents : '', + 'language' => $this->languageForExtension($extension), + ]); + } + + abort_unless($this->isInlinePreviewable($extension, $mimeType), 404); + + return Storage::disk($disk)->response($path, $name, [ + 'Content-Disposition' => 'inline; filename="' . addslashes($name) . '"', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + /** + * Soft-delete or remove an attachment using this existing controller class. + */ + public function destroy(int $attachment): RedirectResponse + { + abort_unless(Schema::hasTable('helpdesk_attachments'), 404); + + $row = DB::table('helpdesk_attachments')->where('id', $attachment)->first(); + + abort_if($row === null, 404); + + $ticketId = $row->ticket_id ?? null; + abort_if($ticketId === null, 404); + + $ticket = Ticket::query()->findOrFail((int) $ticketId); + Gate::authorize('update', $ticket); + + if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { + DB::table('helpdesk_attachments') + ->where('id', $attachment) + ->update(array_filter([ + 'deleted_at' => now(), + 'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? (int) auth()->id() : null, + ], static fn (mixed $value): bool => $value !== null)); + } else { + Storage::disk((string) ($row->disk ?? 'public'))->delete((string) ($row->path ?? '')); + DB::table('helpdesk_attachments')->where('id', $attachment)->delete(); + } + + return redirect() + ->route('helpdesk.tickets.show', $ticket) + ->with('ticket-attachments-deleted', 'Attachment removed.'); + } + + /** + * Determine whether a file can be safely streamed inline by extension or MIME type. + */ + private function isInlinePreviewable(string $extension, string $mimeType): bool + { + return in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'pdf'], true) + || str_starts_with($mimeType, 'image/') + || $mimeType === 'application/pdf'; + } + + /** + * Determine whether a source/configuration file can use the read-only viewer. + */ + private function isSourceExtension(string $extension): bool + { + return in_array($extension, config('helpdesk.allowed_source_extensions', [ + 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', + 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md', + ]), true); + } + + /** + * Return the source preview language key for display/highlighting. + */ + private function languageForExtension(string $extension): string + { + return match ($extension) { + 'php', 'phtml', 'phps' => 'php', + 'js', 'jsx' => 'javascript', + 'ts', 'tsx' => 'typescript', + 'vue' => 'vue', + 'css', 'scss', 'sass', 'less' => 'css', + 'html', 'htm' => 'html', + 'xml' => 'xml', + 'json' => 'json', + 'yaml', 'yml' => 'yaml', + 'sql' => 'sql', + 'sh', 'bash', 'zsh' => 'shell', + 'ps1' => 'powershell', + 'py' => 'python', + 'java', 'kt', 'kts' => 'java', + 'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like', + 'rpg', 'rpgle' => 'rpgle', + 'cl', 'clle' => 'clle', + 'cbl', 'cob', 'copybook' => 'cobol', + default => 'text', + }; + } + } diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php new file mode 100644 index 00000000..02993a9b --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php @@ -0,0 +1,50 @@ +ticket; + + if ($ticket !== null) { + Gate::authorize('view', $ticket); + } + + abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404); + + if ($attachment->isSourceCode()) { + $contents = Storage::disk($attachment->disk)->get($attachment->path); + + return view('helpdesk::attachments.source-preview', [ + 'attachment' => $attachment, + 'contents' => is_string($contents) ? $contents : '', + 'language' => $attachment->language(), + ]); + } + + abort_unless($attachment->isPreviewableBinary(), 404); + + return Storage::disk($attachment->disk)->response($attachment->path, $attachment->original_name, [ + 'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"', + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/Modules/Helpdesk/Models/Attachment.php b/Modules/Helpdesk/Models/Attachment.php index 7f6b86fa..77c3faea 100644 --- a/Modules/Helpdesk/Models/Attachment.php +++ b/Modules/Helpdesk/Models/Attachment.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; final class Attachment extends Model { @@ -17,10 +18,31 @@ final class Attachment extends Model protected $table = 'helpdesk_attachments'; /** @var list */ - protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size']; + protected $fillable = [ + 'ticket_id', + 'comment_id', + 'user_id', + 'disk', + 'path', + 'original_name', + 'mime_type', + 'size', + 'extension', + 'category', + 'checksum_sha256', + 'download_count', + 'last_downloaded_at', + 'deleted_at', + 'deleted_by', + ]; /** @var array */ - protected $casts = ['size' => 'integer']; + protected $casts = [ + 'size' => 'integer', + 'download_count' => 'integer', + 'last_downloaded_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; /** @return BelongsTo */ public function ticket(): BelongsTo @@ -40,8 +62,195 @@ final class Attachment extends Model return $this->belongsTo(User::class, 'user_id'); } + /** + * Return an attribute only when it was actually selected from the database. + * + * Laravel 13 can throw when accessing missing attributes. The Helpdesk + * attachment metadata columns are introduced by an optional forward + * migration, so model helpers must remain safe before that migration has + * been applied. + */ + private function loadedAttribute(string $key): mixed + { + $attributes = $this->getAttributes(); + + return array_key_exists($key, $attributes) ? $this->getAttributeValue($key) : null; + } + + /** + * Return the original client filename with compatibility fallbacks. + */ + public function displayName(): string + { + $name = $this->loadedAttribute('original_name') + ?? $this->loadedAttribute('original_filename') + ?? $this->loadedAttribute('filename') + ?? $this->loadedAttribute('file_name'); + + if (is_string($name) && $name !== '') { + return $name; + } + + return basename((string) ($this->loadedAttribute('path') ?? $this->loadedAttribute('file_path') ?? 'attachment')); + } + + /** + * Return the stored byte size, falling back to legacy names. + */ + public function byteSize(): int + { + return (int) ($this->loadedAttribute('size') ?? $this->loadedAttribute('size_bytes') ?? 0); + } + + /** + * Return the safe download count when the column exists. + */ + public function downloadCount(): int + { + return (int) ($this->loadedAttribute('download_count') ?? 0); + } + + /** + * Determine whether the attachment has been soft deleted. + */ + public function isDeletedAttachment(): bool + { + return filled($this->loadedAttribute('deleted_at')); + } + + /** + * Return the lower-case file extension from stored metadata or filename. + */ + public function extension(): string + { + $extension = $this->loadedAttribute('extension'); + + if (! is_string($extension) || $extension === '') { + $extension = pathinfo($this->displayName(), PATHINFO_EXTENSION); + } + + return strtolower(ltrim((string) $extension, '.')); + } + + /** + * Return the display category used by Helpdesk attachment cards. + */ + public function category(): string + { + $category = $this->loadedAttribute('category'); + + if (is_string($category) && $category !== '') { + return $category; + } + + return match (true) { + $this->isImage() => 'image', + $this->isPdf() => 'pdf', + $this->isArchive() => 'archive', + $this->isSourceCode() => 'source', + default => 'file', + }; + } + + /** + * Determine whether the attachment is a browser-previewable image. + */ + public function isImage(): bool + { + return in_array($this->extension(), ['png', 'jpg', 'jpeg', 'webp', 'gif'], true) + || Str::startsWith((string) $this->loadedAttribute('mime_type'), 'image/'); + } + + /** + * Determine whether the attachment is a PDF document. + */ + public function isPdf(): bool + { + return $this->extension() === 'pdf' || $this->loadedAttribute('mime_type') === 'application/pdf'; + } + + /** + * Determine whether the attachment is an archive. + */ + public function isArchive(): bool + { + return in_array($this->extension(), ['zip', 'tar', 'gz', '7z', 'rar'], true); + } + + /** + * Determine whether the attachment is source/configuration text safe for the read-only viewer. + */ + public function isSourceCode(): bool + { + return in_array($this->extension(), [ + 'php', 'phtml', 'phps', 'blade.php', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', + 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md', + ], true); + } + + /** + * Determine whether the attachment can be streamed inline by the preview route. + */ + public function isPreviewableBinary(): bool + { + return $this->isImage() || $this->isPdf(); + } + + /** + * Determine whether the Helpdesk UI can show an inline preview or viewer link. + */ + public function canPreview(): bool + { + return $this->isSourceCode() || $this->isPreviewableBinary(); + } + + /** + * Return a Font Awesome icon for the attachment type. + */ + public function icon(): string + { + return match ($this->category()) { + 'image' => 'fa-regular fa-file-image', + 'pdf' => 'fa-regular fa-file-pdf', + 'source' => 'fa-solid fa-code', + 'archive' => 'fa-regular fa-file-zipper', + default => 'fa-regular fa-file-lines', + }; + } + + /** + * Return the syntax language key used by the source viewer. + */ + public function language(): string + { + return match ($this->extension()) { + 'php', 'phtml', 'phps', 'blade.php' => 'php', + 'js', 'jsx' => 'javascript', + 'ts', 'tsx' => 'typescript', + 'vue' => 'vue', + 'css', 'scss', 'sass', 'less' => 'css', + 'html', 'htm' => 'html', + 'xml' => 'xml', + 'json' => 'json', + 'yaml', 'yml' => 'yaml', + 'sql' => 'sql', + 'sh', 'bash', 'zsh' => 'shell', + 'ps1' => 'powershell', + 'py' => 'python', + 'java', 'kt', 'kts' => 'java', + 'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like', + 'rpg', 'rpgle' => 'rpgle', + 'cl', 'clle' => 'clle', + 'cbl', 'cob', 'copybook' => 'cobol', + default => 'text', + }; + } + + public function url(): string { - return Storage::disk($this->disk)->url($this->path); + return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path')); } } diff --git a/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php b/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php new file mode 100644 index 00000000..57c7d365 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php @@ -0,0 +1,60 @@ +@php + $lines = preg_split('/\r\n|\r|\n/', $contents); + $downloadUrl = route('helpdesk.attachments.download', $attachment->id); +@endphp + +@extends('helpdesk::layouts.master') + +@section('content') +
+
+
+
+
+

Read-only source viewer

+

{{ $attachment->original_name ?? 'Attachment' }}

+

+ Language: {{ strtoupper($language) }} + • + Size: {{ Number::fileSize($attachment->size) }} +

+
+ +
+ + + Download + + +
+
+
+ +
+
+ + + @foreach ($lines as $index => $line) + + + + + @endforeach + +
{{ $index + 1 }}{{ $line }}
+
+
+
+
+ + +@endsection diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 6e8a4218..66cbd03b 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -39,6 +39,21 @@ document.execCommand(action, false, value); this.syncToHiddenInput(); }, + insertCodeBlock() { + this.$refs.editor.focus(); + document.execCommand('formatBlock', false, 'pre'); + this.syncToHiddenInput(); + }, + handlePaste(event) { + const files = Array.from(event.clipboardData?.files || []); + const images = files.filter((file) => file.type.startsWith('image/')); + + if (images.length > 0) { + window.dispatchEvent(new CustomEvent('helpdesk-editor-files-pasted', { detail: { files: images } })); + } + + this.$nextTick(() => this.syncToHiddenInput()); + }, clear() { this.value = ''; this.$refs.editor.innerHTML = ''; @@ -80,6 +95,8 @@ class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List +