new ataxchments preview code

This commit is contained in:
Mark Gregory
2026-06-24 10:40:19 +01:00
parent a42b5787b9
commit c44b362710
17 changed files with 1071 additions and 43 deletions
+9
View File
@@ -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',
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add optional attachment metadata used by previews, source viewing and audit trails.
*/
public function up(): void
{
Schema::table('helpdesk_attachments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_attachments', 'extension')) {
$table->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);
}
}
});
}
};
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Services\AttachmentService;
final class AttachmentDeleteController extends Controller
{
/**
* Soft-delete a Helpdesk attachment after enforcing ticket update access.
*/
public function __invoke(Attachment $attachment, AttachmentService $attachments): RedirectResponse
{
$ticket = $attachment->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.');
}
}
@@ -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',
};
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Modules\Helpdesk\Models\Attachment;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AttachmentPreviewController extends Controller
{
/**
* Preview a safe Helpdesk attachment after enforcing ticket access.
*
* @throws FileNotFoundException
*/
public function __invoke(Attachment $attachment): View|StreamedResponse|RedirectResponse
{
$ticket = $attachment->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',
]);
}
}
+212 -3
View File
@@ -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<string> */
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<string, string> */
protected $casts = ['size' => 'integer'];
protected $casts = [
'size' => 'integer',
'download_count' => 'integer',
'last_downloaded_at' => 'datetime',
'deleted_at' => 'datetime',
];
/** @return BelongsTo<Ticket, Attachment> */
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'));
}
}
@@ -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')
<div class="min-h-screen bg-zinc-950 px-4 py-6 text-zinc-100 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl space-y-4">
<header class="rounded-2xl border border-zinc-700 bg-zinc-900 p-5 shadow-sm">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.18em] text-blue-300">Read-only source viewer</p>
<h1 class="mt-1 truncate text-2xl font-black text-white">{{ $attachment->original_name ?? 'Attachment' }}</h1>
<p class="mt-2 text-sm text-zinc-400">
Language: <span class="font-semibold text-zinc-200">{{ strtoupper($language) }}</span>
<span class="mx-2 text-zinc-600">•</span>
Size: <span class="font-semibold text-zinc-200">{{ Number::fileSize($attachment->size) }}</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $downloadUrl }}"
class="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-700">
<i class="fa-solid fa-download" aria-hidden="true"></i>
Download
</a>
<button type="button" data-helpdesk-copy-source
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-4 py-2 text-sm font-bold text-white hover:bg-green-700">
<i class="fa-regular fa-copy" aria-hidden="true"></i>
Copy code
</button>
</div>
</div>
</header>
<section class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
<div class="overflow-auto">
<table class="min-w-full border-collapse font-mono text-xs leading-6">
<tbody>
@foreach ($lines as $index => $line)
<tr class="group hover:bg-zinc-900/80">
<td class="select-none border-r border-zinc-800 bg-zinc-900 px-4 text-right text-zinc-500">{{ $index + 1 }}</td>
<td class="whitespace-pre px-4 text-zinc-100"><code>{{ $line }}</code></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
</div>
</div>
<script>
document.querySelector('[data-helpdesk-copy-source]')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(@js($contents));
});
</script>
@endsection
@@ -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</button>
<button type="button" x-on:click="command('formatBlock', 'blockquote')"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Quote</button>
<button type="button" x-on:click="insertCodeBlock()"
class="rounded-lg px-2 py-1 text-sm font-mono hover:bg-zinc-200 dark:hover:bg-zinc-800">Code</button>
<button type="button" x-on:click="const url = prompt('URL'); if (url) command('createLink', url)"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button>
<button type="button" x-on:click="command('removeFormat')"
@@ -88,13 +105,13 @@
<div x-ref="editor" contenteditable="true" spellcheck="true" lang="en-GB" autocapitalize="sentences" autocorrect="on" autocomplete="off" inputmode="text"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
x-on:paste="$nextTick(() => syncToHiddenInput())" x-on:blur="syncToHiddenInput()"
x-on:paste="handlePaste($event)" x-on:blur="syncToHiddenInput()"
class="helpdesk-rich-editor prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
data-placeholder="{{ $placeholder }}"></div>
</div>
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
Browser spellcheck is enabled for this rich editor. Misspelled words are checked by the browser/operating system while typing.
Browser spellcheck is enabled. Use Code for snippets, and paste screenshots directly into the editor to attach them to the ticket.
</p>
@if ($model)
@@ -259,7 +259,16 @@
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="helpdeskAttachmentDropzone('attachments')"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dropFiles($event)"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
@@ -267,7 +276,7 @@
</label>
<p class="mt-1 text-sm text-zinc-500">
Add screenshots, documents, logs or supporting files to help resolve the ticket.
Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.
</p>
</div>
@@ -278,12 +287,38 @@
</label>
</div>
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only">
<label for="attachments"
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Drop files here or click to browse</span>
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
</label>
<div wire:loading.flex wire:target="attachments"
class="mt-4 items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
Uploading attachments...
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
x-ref="input" x-on:change="refreshSelectedFiles()">
<template x-if="selectedFiles.length > 0">
<div class="mt-4 grid gap-2 sm:grid-cols-2">
<template x-for="file in selectedFiles" :key="file.name + file.size">
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300">
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
</div>
</template>
</div>
</template>
<div x-show="uploading" x-cloak
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
<span x-text="progress + '%'"></span>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-blue-500 transition-all" x-bind:style="`width: ${progress}%`"></div>
</div>
</div>
@error('attachments')
@@ -295,8 +330,6 @@
@enderror
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
@@ -324,13 +357,65 @@
</form>
<script>
(() => {
window.helpdeskAttachmentDropzone = window.helpdeskAttachmentDropzone || function (inputId) {
return {
dragging: false,
uploading: false,
progress: 0,
selectedFiles: [],
dropFiles(event) {
this.dragging = false;
const input = this.$refs.input || document.getElementById(inputId);
if (!input || !event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
this.refreshSelectedFiles();
},
refreshSelectedFiles() {
const input = this.$refs.input || document.getElementById(inputId);
this.selectedFiles = input?.files ? Array.from(input.files) : [];
},
formatSize(bytes) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
},
};
};
const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true }));
});
};
const appendPastedFiles = (event) => {
const files = Array.from(event.detail?.files || []);
const input = document.querySelector('.helpdesk-ticket-form #attachments');
if (!input || files.length === 0) {
return;
}
const transfer = new DataTransfer();
Array.from(input.files || []).forEach((file) => transfer.items.add(file));
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles);
document.addEventListener('helpdesk-editor-files-pasted', appendPastedFiles);
if (window.Livewire?.on) {
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
@@ -170,30 +170,76 @@
</form>
@endcan
@if ($ticket->attachments->isEmpty())
@php
$visibleAttachments = $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment());
@endphp
@if ($visibleAttachments->isEmpty())
<div
class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-xs font-semibold text-zinc-500 dark:border-zinc-700">
No supporting files have been attached yet.
</div>
@else
<div class="grid gap-2 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
class="group/file flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-xs transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
<span class="min-w-0">
<span class="block truncate font-bold text-zinc-900 dark:text-zinc-100">
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
</span>
<span class="block text-[0.7rem] text-zinc-500">
{{ Number::fileSize($attachment->size ?? ($attachment->size_bytes ?? 0)) }}
</span>
</span>
<div class="grid gap-3 sm:grid-cols-2">
@foreach ($visibleAttachments as $attachment)
<article
class="group/file overflow-hidden rounded-xl border border-zinc-200 bg-white text-xs shadow-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950 dark:hover:bg-zinc-900">
@if ($attachment->isImage())
<a href="{{ route('helpdesk.attachments.preview', $attachment->id) }}" target="_blank"
class="block aspect-video overflow-hidden bg-zinc-100 dark:bg-zinc-900">
<img src="{{ route('helpdesk.attachments.preview', $attachment->id) }}" alt="{{ $attachment->displayName() }}"
class="h-full w-full object-cover transition group-hover/file:scale-[1.02]">
</a>
@endif
<span
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-700 transition group-hover/file:bg-blue-600 group-hover/file:text-white dark:bg-zinc-800 dark:text-zinc-200">
<i class="fa-solid fa-download text-xs" aria-hidden="true"></i>
</span>
</a>
<div class="flex items-start gap-3 p-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
<i class="{{ $attachment->icon() }} text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-bold text-zinc-900 dark:text-zinc-100">
{{ $attachment->displayName() }}
</p>
<p class="mt-1 text-[0.7rem] font-semibold uppercase tracking-wide text-zinc-500">
{{ strtoupper($attachment->language()) }} · {{ Number::fileSize($attachment->byteSize()) }}
@if ($attachment->downloadCount() > 0)
· {{ $attachment->downloadCount() }} download{{ $attachment->downloadCount() === 1 ? '' : 's' }}
@endif
</p>
<div class="mt-3 flex flex-wrap gap-2">
@if ($attachment->canPreview())
<a href="{{ route('helpdesk.attachments.preview', $attachment->id) }}" target="_blank"
class="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-green-700">
<i class="fa-regular fa-eye" aria-hidden="true"></i>
Preview
</a>
@endif
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
class="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-blue-700">
<i class="fa-solid fa-download" aria-hidden="true"></i>
Download
</a>
@can('update', $ticket)
<form method="POST" action="{{ route('helpdesk.attachments.destroy', $attachment->id) }}"
onsubmit="return confirm('Remove this attachment from the ticket?');">
@csrf
@method('DELETE')
<button type="submit"
class="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-red-700">
<i class="fa-regular fa-trash-can" aria-hidden="true"></i>
Remove
</button>
</form>
@endcan
</div>
</div>
</div>
</article>
@endforeach
</div>
@endif
+6
View File
@@ -40,5 +40,11 @@ Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
->whereNumber('attachment')
->name('attachments.download');
Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview'])
->whereNumber('attachment')
->name('attachments.preview');
Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy'])
->whereNumber('attachment')
->name('attachments.destroy');
Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store'])
->name('tickets.time-logs.store');
+95 -10
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\Helpdesk\Models\Attachment;
@@ -43,16 +45,16 @@ final class AttachmentService
$size = (int) Storage::disk($disk)->size($path);
}
Attachment::query()->create([
'ticket_id' => $ticket->id,
'comment_id' => $comment?->id,
'user_id' => $userId,
'disk' => $disk,
'path' => $path,
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
]);
Attachment::query()->create($this->attachmentAttributes(
ticket: $ticket,
comment: $comment,
userId: $userId,
disk: $disk,
path: $path,
originalName: $originalName,
mimeType: $mimeType,
size: $size,
));
}
}
@@ -65,6 +67,89 @@ final class AttachmentService
$attachment->delete();
}
/**
* Build attachment attributes while remaining compatible with older databases.
*
* @return array<string, mixed>
*/
private function attachmentAttributes(Ticket $ticket, ?Comment $comment, int $userId, string $disk, string $path, string $originalName, string $mimeType, int $size): array
{
$attributes = [
'ticket_id' => $ticket->id,
'comment_id' => $comment?->id,
'user_id' => $userId,
'disk' => $disk,
'path' => $path,
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
];
if (Schema::hasColumn('helpdesk_attachments', 'extension')) {
$attributes['extension'] = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
}
if (Schema::hasColumn('helpdesk_attachments', 'category')) {
$attributes['category'] = $this->categoryFor($originalName, $mimeType);
}
if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if (is_resource($stream)) {
$context = hash_init('sha256');
hash_update_stream($context, $stream);
$attributes['checksum_sha256'] = hash_final($context);
fclose($stream);
}
}
return $attributes;
}
/**
* Categorise uploaded files for icons, previews and safe code viewing.
*/
private function categoryFor(string $originalName, string $mimeType): string
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
if (str_starts_with($mimeType, 'image/') || in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) {
return 'image';
}
if ($extension === 'pdf' || $mimeType === 'application/pdf') {
return 'pdf';
}
if (in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true)) {
return 'archive';
}
if (in_array($extension, config('helpdesk.allowed_source_extensions', []), true)) {
return 'source';
}
return 'file';
}
/**
* Soft-delete a stored attachment while retaining audit metadata.
*/
public function softDelete(Attachment $attachment, int $userId): void
{
DB::table('helpdesk_attachments')
->where('id', $attachment->id)
->update(array_filter([
'deleted_at' => now(),
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? $userId : null,
], static fn (mixed $value): bool => $value !== null));
}
/**
* Determine whether the value is a storeable uploaded file.
*/
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketAttachmentExperienceFilesTest extends TestCase
{
/** @test */
public function create_ticket_view_has_drag_drop_upload_progress_and_paste_attachment_wiring(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
self::assertIsString($view);
self::assertStringContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringContainsString('livewire-upload-progress', $view);
self::assertStringContainsString('DataTransfer', $view);
self::assertStringContainsString('helpdesk-editor-files-pasted', $view);
self::assertIsString($editor);
self::assertStringContainsString('insertCodeBlock', $editor);
self::assertStringContainsString('handlePaste', $editor);
self::assertStringContainsString('spellcheck="true"', $editor);
}
/** @test */
public function attachment_preview_routes_controllers_and_metadata_are_present(): void
{
$routes = file_get_contents(base_path('Modules/Helpdesk/Routes/web.php'));
$model = file_get_contents(base_path('Modules/Helpdesk/Models/Attachment.php'));
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$migration = file_get_contents(base_path('Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php'));
$sourcePreview = base_path('Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php');
self::assertIsString($routes);
self::assertStringContainsString('attachments.preview', $routes);
self::assertStringContainsString('attachments.destroy', $routes);
self::assertStringNotContainsString('AttachmentPreviewController::class', $routes);
self::assertStringNotContainsString('AttachmentDeleteController::class', $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'preview']", $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'destroy']", $routes);
self::assertFileExists($sourcePreview);
self::assertStringContainsString('Read-only source viewer', (string) file_get_contents($sourcePreview));
self::assertIsString($model);
self::assertStringContainsString('function isSourceCode', $model);
self::assertStringContainsString('function canPreview', $model);
self::assertStringContainsString('function language', $model);
self::assertStringContainsString('function loadedAttribute', $model);
self::assertStringContainsString('function displayName', $model);
self::assertStringContainsString('function byteSize', $model);
self::assertStringContainsString('function downloadCount', $model);
self::assertStringContainsString('function isDeletedAttachment', $model);
self::assertStringNotContainsString('$this->extension ?: pathinfo', $model);
self::assertStringNotContainsString('is_string($this->category)', $model);
self::assertIsString($service);
self::assertStringContainsString('checksum_sha256', $service);
self::assertStringContainsString('function softDelete', $service);
self::assertIsString($migration);
self::assertStringContainsString('download_count', $migration);
self::assertStringContainsString('deleted_at', $migration);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateAttachmentPersistenceTest extends TestCase
{
/**
* The create form must not submit while Livewire is still uploading files,
* otherwise the ticket can be created before attachments reach the component.
*/
public function test_ticket_create_submit_is_blocked_while_attachments_are_uploading(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"', $view);
$this->assertStringContainsString('x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('wire:target="save,attachments"', $view);
$this->assertStringContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view);
$this->assertStringContainsString('$event.preventDefault();', $view);
}
/**
* The shared attachment service must explicitly support Livewire temporary
* uploads as well as controller UploadedFile instances.
*/
public function test_attachment_service_supports_livewire_temporary_uploads(): void
{
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$this->assertIsString($service);
$this->assertStringContainsString('TemporaryUploadedFile', $service);
$this->assertStringContainsString('isSupportedUpload', $service);
$this->assertStringContainsString('$file->store(\'tickets/\' . $ticket->id, $disk)', $service);
$this->assertStringNotContainsString('! $file instanceof UploadedFile || ! $file->isValid()', $service);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketSourceAttachmentFilesTest extends TestCase
{
/**
* Source-code uploads must be extension based because browsers and Symfony
* often report source files as text/plain or application/octet-stream.
*/
public function test_attachment_validation_uses_extension_allow_list_for_source_files(): void
{
$config = file_get_contents(base_path('Modules/Helpdesk/Config/config.php'));
$this->assertIsString($config);
$this->assertStringContainsString("'allowed_extensions'", $config);
$this->assertStringContainsString("'php'", $config);
$this->assertStringContainsString("'js'", $config);
$this->assertStringContainsString("'rpg'", $config);
$this->assertStringContainsString("'rpgle'", $config);
}
/**
* All Helpdesk upload entry points should use the shared source-safe rules.
*/
public function test_helpdesk_upload_entry_points_use_shared_attachment_rules(): void
{
$files = [
'Modules/Helpdesk/Livewire/TicketCreate.php',
'Modules/Helpdesk/Livewire/TicketEdit.php',
'Modules/Helpdesk/Livewire/TicketShow.php',
'Modules/Helpdesk/Http/Controllers/TicketStoreController.php',
'Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php',
'Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php',
];
foreach ($files as $file) {
$contents = file_get_contents(base_path($file));
$this->assertIsString($contents);
$this->assertStringContainsString('extensions:', $contents, $file);
$this->assertStringNotContainsString('mimes:', $contents, $file);
$this->assertStringNotContainsString('AttachmentValidation', $contents, $file);
}
}
}
+52
View File
@@ -264,3 +264,55 @@
- Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files.
- Added regression coverage for create-form upload guarding and Livewire temporary upload support.
- Preserved append-only changelog history.
- version: 1.10.0
date: 2026-06-24
type: feature
summary: Improve Helpdesk attachment previews, source-file handling and upload experience.
changes:
- Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress.
- Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync.
- Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files.
- Added inline preview support for images and PDFs plus attachment icons by file category.
- Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion.
- Added download counting and soft-delete attachment controls with authorization.
- Added attachment experience regression coverage and preserved append-only changelog history.
- version: 1.10.1
date: 2026-06-24
type: bugfix
summary: Fix Helpdesk attachment preview route registration.
changes:
- Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated.
- Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments.
- Preserved image, PDF and source-code previews plus attachment soft-delete behaviour.
- Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes.
- version: 1.10.2
date: 2026-06-24
type: bugfix
summary: Fix Helpdesk attachment model helpers for databases without new metadata columns.
changes:
- Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at.
- Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state.
- Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run.
- Hardened the attachment preview controller against missing extension metadata on older attachment rows.
- Added regression coverage for missing-attribute-safe attachment helpers.
- Preserved append-only changelog history.
- version: 1.10.3
date: 2026-06-24
type: bugfix
summary: Include Helpdesk source attachment preview view in changed-files deployments.
changes:
- Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error.
- Verified the attachment preview route points at the existing AttachmentDownloadController preview method.
- Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it.
- Preserved append-only changelog history.
- version: 1.10.4
date: 2026-06-24
changes:
- Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly.
- Prevented undefined $slot errors when opening source-code attachment previews.
- Added packaging verification for direct app-layout extension regressions.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.55"
"version": "1.10.4"
}