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)); 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', }; } }