Initial Commit
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<?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\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class AttachmentDownloadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Download a ticket attachment through Laravel storage instead of a public URL.
|
||||
*
|
||||
* This avoids broken public storage links and keeps ticket authorization enforced.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function __invoke(int $attachment): 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));
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user