diff --git a/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php new file mode 100644 index 00000000..e95ad36f --- /dev/null +++ b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php @@ -0,0 +1,63 @@ +error('No active ChatGPT/OpenAI API key is stored.'); + return self::FAILURE; + } + + $service = new OpenAiFileService((string) $setting->api_key); + + try { + if ((bool) $this->option('dry-run')) { + $files = $service->listFiles(); + + $this->info('Found ' . count($files) . ' OpenAI API file(s).'); + + foreach ($files as $file) { + $this->line(sprintf( + '- %s %s', + (string) ($file['id'] ?? 'unknown'), + (string) ($file['filename'] ?? 'unnamed') + )); + } + + return self::SUCCESS; + } + + $result = $service->deleteAllFiles(); + + $this->info("Listed: {$result['listed']}"); + $this->info("Deleted: {$result['deleted']}"); + $this->info("Failed: {$result['failed']}"); + + foreach ($result['failures'] as $failure) { + $this->error($failure['file_id'] . ': ' . $failure['error']); + } + + return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE; + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + return self::FAILURE; + } + } +} diff --git a/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php new file mode 100644 index 00000000..7380e84a --- /dev/null +++ b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name')->default('OpenAI API Key'); + $table->text('api_key'); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chatgpt_api_settings'); + } +}; diff --git a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php index 726b6848..a207759e 100644 --- a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php +++ b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php @@ -3,77 +3,80 @@ namespace Modules\ChatGPT\Http\Controllers; use Illuminate\Contracts\Support\Renderable; -use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Illuminate\Routing\Controller; +use Modules\ChatGPT\Http\Requests\StoreChatGptApiSettingRequest; +use Modules\ChatGPT\Http\Requests\UpdateChatGptApiSettingRequest; +use Modules\ChatGPT\Models\ChatGptApiSetting; class ChatGPTController extends Controller { /** - * Display a listing of the resource. - * @return Renderable + * Display the ChatGPT module settings page. */ - public function index() + public function index(): Renderable { - return view('chatgpt::index'); + return view('chatgpt::index', [ + 'settings' => ChatGptApiSetting::query()->latest('id')->get(), + ]); } /** - * Show the form for creating a new resource. - * @return Renderable + * Store a new encrypted OpenAI API key setting. */ - public function create() + public function store(StoreChatGptApiSettingRequest $request): RedirectResponse { - return view('chatgpt::create'); + $data = $request->validated(); + + ChatGptApiSetting::query()->update(['is_active' => false]); + + ChatGptApiSetting::query()->create([ + 'name' => $data['name'] ?: 'OpenAI API Key', + 'api_key' => $data['api_key'], + 'is_active' => (bool) ($data['is_active'] ?? true), + ]); + + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key saved.'); } /** - * Store a newly created resource in storage. - * @param Request $request - * @return Renderable + * Update an encrypted OpenAI API key setting. */ - public function store(Request $request) + public function update(UpdateChatGptApiSettingRequest $request, ChatGptApiSetting $setting): RedirectResponse { - // + $data = $request->validated(); + + if ((bool) ($data['is_active'] ?? false)) { + ChatGptApiSetting::query() + ->whereKeyNot($setting->getKey()) + ->update(['is_active' => false]); + } + + $setting->name = $data['name'] ?: $setting->name; + $setting->is_active = (bool) ($data['is_active'] ?? false); + + if (! empty($data['api_key'])) { + $setting->api_key = $data['api_key']; + } + + $setting->save(); + + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key updated.'); } /** - * Show the specified resource. - * @param int $id - * @return Renderable + * Delete an encrypted OpenAI API key setting. */ - public function show($id) + public function destroy(ChatGptApiSetting $setting): RedirectResponse { - return view('chatgpt::show'); - } + $setting->delete(); - /** - * Show the form for editing the specified resource. - * @param int $id - * @return Renderable - */ - public function edit($id) - { - return view('chatgpt::edit'); - } - - /** - * Update the specified resource in storage. - * @param Request $request - * @param int $id - * @return Renderable - */ - public function update(Request $request, $id) - { - // - } - - /** - * Remove the specified resource from storage. - * @param int $id - * @return Renderable - */ - public function destroy($id) - { - // + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key deleted.'); } } diff --git a/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php new file mode 100644 index 00000000..b57226ff --- /dev/null +++ b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'api_key' => ['required', 'string', 'min:20', 'max:500'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php new file mode 100644 index 00000000..f1b8387a --- /dev/null +++ b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'api_key' => ['nullable', 'string', 'min:20', 'max:500'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/ChatGPT/Models/ChatGptApiSetting.php b/Modules/ChatGPT/Models/ChatGptApiSetting.php new file mode 100644 index 00000000..215a8320 --- /dev/null +++ b/Modules/ChatGPT/Models/ChatGptApiSetting.php @@ -0,0 +1,53 @@ + 'encrypted', + 'is_active' => 'boolean', + ]; + + protected $hidden = [ + 'api_key', + ]; + + /** + * Get the currently active OpenAI API setting. + */ + public static function active(): ?self + { + return self::query() + ->where('is_active', true) + ->latest('id') + ->first(); + } + + /** + * Return a masked version of the stored API key for display. + */ + public function maskedApiKey(): string + { + $key = (string) $this->api_key; + + if ($key === '') { + return 'Not set'; + } + + return substr($key, 0, 7) . '...' . substr($key, -4); + } +} diff --git a/Modules/ChatGPT/Resources/views/index.blade.php b/Modules/ChatGPT/Resources/views/index.blade.php index bc25acc3..83f2de0d 100644 --- a/Modules/ChatGPT/Resources/views/index.blade.php +++ b/Modules/ChatGPT/Resources/views/index.blade.php @@ -1,9 +1,171 @@ @extends('chatgpt::layouts.master') @section('content') -

Hello World

+
+
+
+
+

ChatGPT API Settings

+

+ Store the OpenAI API key used by the module command. Keys are encrypted at rest by Laravel. +

+
-

- This view is loaded from module: {!! config('chatgpt.name') !!} -

+ + php artisan chatgpt:delete-api-files + +
+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+ Unable to save the API key. + +
+ @endif + +
+

Create API Key

+ +
+ @csrf + +
+ + +
+ +
+ + +
+ + + +
+ +
+
+
+ +
+

Stored API Keys

+ +
+ + + + + + + + + + + + @forelse ($settings as $setting) + + + + + + + + @empty + + + + @endforelse + +
NameKeyActiveUpdatedActions
+
+ @csrf + @method('PUT') + + + + + + +
+
{{ $setting->maskedApiKey() }} + @if ($setting->is_active) + Yes + @else + No + @endif + {{ $setting->updated_at?->format('d M Y H:i') }} +
+ + +
+ @csrf + @method('DELETE') + +
+
+
+ No API keys have been stored yet. +
+
+
+
@endsection diff --git a/Modules/ChatGPT/Routes/web.php b/Modules/ChatGPT/Routes/web.php index 5006d946..98f524e1 100644 --- a/Modules/ChatGPT/Routes/web.php +++ b/Modules/ChatGPT/Routes/web.php @@ -1,16 +1,14 @@ group(function() { - Route::get('/', 'ChatGPTController@index'); -}); +Route::middleware(['web', 'auth']) + ->prefix('chatgpt') + ->name('chatgpt.') + ->group(function (): void { + Route::get('/', [ChatGPTController::class, 'index'])->name('index'); + Route::post('/settings', [ChatGPTController::class, 'store'])->name('settings.store'); + Route::put('/settings/{setting}', [ChatGPTController::class, 'update'])->name('settings.update'); + Route::delete('/settings/{setting}', [ChatGPTController::class, 'destroy'])->name('settings.destroy'); + }); diff --git a/Modules/ChatGPT/Services/OpenAiFileService.php b/Modules/ChatGPT/Services/OpenAiFileService.php new file mode 100644 index 00000000..7077e44f --- /dev/null +++ b/Modules/ChatGPT/Services/OpenAiFileService.php @@ -0,0 +1,121 @@ +> + */ + public function listFiles(): array + { + $files = []; + $after = null; + + do { + $query = ['limit' => 100]; + + if (is_string($after) && $after !== '') { + $query['after'] = $after; + } + + $response = $this->client() + ->get(self::BASE_URL . '/files', $query); + + if ($response->failed()) { + throw new RuntimeException('OpenAI file list request failed: ' . $response->body()); + } + + $payload = $response->json(); + $batch = $payload['data'] ?? []; + + foreach ($batch as $file) { + if (is_array($file)) { + $files[] = $file; + } + } + + $hasMore = (bool) ($payload['has_more'] ?? false); + $after = $payload['last_id'] ?? null; + } while ($hasMore && is_string($after) && $after !== ''); + + return $files; + } + + /** + * Delete one OpenAI API file by ID. + * + * @return array + */ + public function deleteFile(string $fileId): array + { + $response = $this->client() + ->delete(self::BASE_URL . '/files/' . rawurlencode($fileId)); + + if ($response->failed()) { + throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body()); + } + + return $response->json() ?? []; + } + + /** + * Delete every OpenAI API file visible to the configured API key. + * + * @return array{listed:int, deleted:int, failed:int, failures:array} + */ + public function deleteAllFiles(): array + { + $files = $this->listFiles(); + $deleted = 0; + $failures = []; + + foreach ($files as $file) { + $fileId = (string) ($file['id'] ?? ''); + + if ($fileId === '') { + continue; + } + + try { + $this->deleteFile($fileId); + $deleted++; + } catch (RuntimeException $exception) { + $failures[] = [ + 'file_id' => $fileId, + 'error' => $exception->getMessage(), + ]; + } + } + + return [ + 'listed' => count($files), + 'deleted' => $deleted, + 'failed' => count($failures), + 'failures' => $failures, + ]; + } + + /** + * Build the authenticated OpenAI HTTP client. + */ + private function client(): PendingRequest + { + return Http::withToken($this->apiKey) + ->acceptJson() + ->timeout(60) + ->retry(2, 500); + } +} diff --git a/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php new file mode 100644 index 00000000..d29326b5 --- /dev/null +++ b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php @@ -0,0 +1,49 @@ +create([ + 'name' => 'OpenAI API Key', + 'api_key' => 'sk-test-value-that-is-long-enough', + 'is_active' => true, + ]); + + $raw = $setting->getRawOriginal('api_key'); + + $this->assertNotSame('sk-test-value-that-is-long-enough', $raw); + $this->assertSame('sk-test-value-that-is-long-enough', $setting->api_key); + } + + /** + * It resolves the latest active API key setting. + */ + public function test_it_resolves_latest_active_api_key_setting(): void + { + ChatGptApiSetting::query()->create([ + 'name' => 'Inactive', + 'api_key' => 'sk-inactive-value-that-is-long-enough', + 'is_active' => false, + ]); + + $active = ChatGptApiSetting::query()->create([ + 'name' => 'Active', + 'api_key' => 'sk-active-value-that-is-long-enough', + 'is_active' => true, + ]); + + $this->assertTrue($active->is(ChatGptApiSetting::active())); + } +} diff --git a/Modules/ChatGPT/changelog.yaml b/Modules/ChatGPT/changelog.yaml new file mode 100644 index 00000000..e6ef5636 --- /dev/null +++ b/Modules/ChatGPT/changelog.yaml @@ -0,0 +1,8 @@ +- version: 1.1.0 + date: 2026-06-24 + changes: + - Added encrypted database storage for OpenAI API keys. + - Added ChatGPT module settings UI to create, edit, activate, and delete stored API keys. + - Added chatgpt:delete-api-files command with dry-run support and no interactive confirmation. + - Added OpenAI files service for listing and deleting API files. + - Added feature tests for encrypted key storage and active key resolution. diff --git a/Modules/ChatGPT/module.json b/Modules/ChatGPT/module.json index 9b76c95d..f547c4b5 100644 --- a/Modules/ChatGPT/module.json +++ b/Modules/ChatGPT/module.json @@ -1,7 +1,7 @@ { "name": "ChatGPT", "alias": "chatgpt", - "version": "1.0.0", + "version": "1.1.0", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/ChatGPT/readme.md b/Modules/ChatGPT/readme.md index e69de29b..fd961169 100644 --- a/Modules/ChatGPT/readme.md +++ b/Modules/ChatGPT/readme.md @@ -0,0 +1,28 @@ +# ChatGPT Module + +Provides encrypted OpenAI API key storage and an Artisan command for deleting all OpenAI API files visible to the active stored key. + +## Routes + +- `GET /chatgpt` - manage stored API keys. +- `POST /chatgpt/settings` - create a stored API key. +- `PUT /chatgpt/settings/{setting}` - update an API key setting. +- `DELETE /chatgpt/settings/{setting}` - delete an API key setting. + +## Commands + +Preview files without deleting: + +```bash +php artisan chatgpt:delete-api-files --dry-run +``` + +Delete all OpenAI API files without interactive confirmation: + +```bash +php artisan chatgpt:delete-api-files +``` + +## Security + +API keys are encrypted at rest using Laravel's encrypted Eloquent cast and the application `APP_KEY`. diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index 521e7582..c5922cfa 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -7,7 +7,16 @@ return [ 'middleware' => ['web', 'auth'], 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), - 'allowed_mimes' => ['md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], + 'allowed_extensions' => [ + 'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip', + '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', + ], + + // Backwards-compatible alias for older module code/tests that still read this key. + 'allowed_mimes' => [], 'default_status_slug' => 'new', 'closed_status_slug' => 'closed', ]; diff --git a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php index d963ef43..05ace4c5 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php @@ -22,12 +22,9 @@ final class TicketAttachmentStoreController extends Controller { Gate::authorize('update', $ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']); - $request->validate([ 'attachments' => ['required', 'array', 'min:1'], - 'attachments.*' => ['required', 'file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(required: true), ]); $user = Auth::user(); @@ -43,4 +40,36 @@ final class TicketAttachmentStoreController extends Controller ->route('helpdesk.tickets.show', $ticket) ->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.'); } + + /** + * 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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php index c3ba3d2b..4026c47a 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php @@ -29,9 +29,6 @@ final class TicketCommentStoreController extends Controller { Gate::authorize('comment', $ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']); - $validated = $request->validate([ 'subject' => ['nullable', 'string', 'max:180'], 'commentBody' => ['nullable', 'string'], @@ -43,7 +40,7 @@ final class TicketCommentStoreController extends Controller ], 'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')], 'attachments' => ['nullable', 'array'], - 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -168,4 +165,36 @@ final class TicketCommentStoreController extends Controller || Str::lower((string) ($company->slug ?? '')) === 'mpba-limited' || Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba'); } + + /** + * 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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php index 22acdebe..6313bcbd 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php @@ -21,9 +21,6 @@ final class TicketStoreController extends Controller */ public function __invoke(Request $request, TicketService $tickets): RedirectResponse { - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx']); - $validated = $request->validate([ 'company_id' => ['nullable', 'integer'], 'subject' => ['required', 'string', 'max:200'], @@ -35,7 +32,7 @@ final class TicketStoreController extends Controller 'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')], 'due_at' => ['nullable', 'date'], 'attachments' => ['nullable', 'array'], - 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -61,4 +58,36 @@ final class TicketStoreController extends Controller ->route('helpdesk.tickets.index') ->with('success', 'Ticket ' . $ticket->reference . ' has been created.'); } + + /** + * 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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index 6256dbc3..3cbb635e 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -74,11 +74,7 @@ final class TicketCreate extends Component 'tag_ids' => ['array'], 'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'], 'attachments' => ['array'], - 'attachments.*' => [ - 'file', - 'max:' . config('helpdesk.max_upload_kb'), - 'mimes:' . implode(',', config('helpdesk.allowed_mimes')), - ], + 'attachments.*' => $this->attachmentFileRules(), ]; } @@ -318,4 +314,37 @@ final class TicketCreate extends Component { return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default'); } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index be75f718..8b0f7c58 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -109,7 +109,7 @@ final class TicketEdit extends Component $this->validate(); $this->validate([ 'attachments' => 'array', - 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), + 'attachments.*' => $this->attachmentFileRules(), 'tag_ids.*' => 'integer|exists:helpdesk_tags,id', ]); @@ -317,4 +317,37 @@ final class TicketEdit extends Component return $payload; } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index bd3428f9..2e05558b 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -92,7 +92,7 @@ final class TicketShow extends Component $this->validate([ 'commentBody' => ['required', 'string', 'min:2'], - 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), + 'attachments.*' => $this->attachmentFileRules(), ]); $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); @@ -299,12 +299,9 @@ final class TicketShow extends Component { $this->authorize('comment', $this->ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])); - $this->validate([ 'supportingFiles' => ['required', 'array', 'min:1'], - 'supportingFiles.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . $allowedMimes], + 'supportingFiles.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -673,4 +670,37 @@ final class TicketShow extends Component return $comment; })->values(); } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 32b2db16..85ebbfd4 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp
@vite(['resources/css/app.css', 'resources/js/app.js']) @include('helpdesk::partials.page-header', [ @@ -7,7 +14,24 @@ 'Raise a new support request with priority, product/service, project, category, team and full details.', ]) -
+

Ticket details

@@ -254,7 +278,7 @@
- +
@@ -289,9 +313,10 @@
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index cd405b01..e74fb070 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp
@vite(['resources/css/app.css', 'resources/js/app.js']) @include('helpdesk::partials.page-header', [ @@ -323,7 +330,7 @@
-
filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp @php use Illuminate\Support\Str; use Modules\Helpdesk\Models\TicketTimeLog; @@ -141,7 +148,7 @@ class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs"> Choose files - @@ -456,7 +463,7 @@ class="inline-flex cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70"> Choose files -
diff --git a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php index 831bec7d..12d08920 100644 --- a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php +++ b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp @props(['comments', 'level' => 0, 'ticket' => null, 'canSeeInternalNotes' => false])
@@ -285,7 +292,7 @@
diff --git a/Modules/Helpdesk/Services/AttachmentService.php b/Modules/Helpdesk/Services/AttachmentService.php index 46320ad4..db117d95 100644 --- a/Modules/Helpdesk/Services/AttachmentService.php +++ b/Modules/Helpdesk/Services/AttachmentService.php @@ -6,27 +6,40 @@ namespace Modules\Helpdesk\Services; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Modules\Helpdesk\Models\Attachment; use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Ticket; final class AttachmentService { + /** + * Store multiple Helpdesk attachments against a ticket or comment. + * + * Livewire create/comment forms pass TemporaryUploadedFile instances, while + * controller endpoints pass Symfony/Laravel UploadedFile instances. Both + * are supported here so uploads are never silently skipped on ticket create. + * + * @param array $files + */ public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void { foreach ($files as $file) { - if (! $file instanceof UploadedFile || ! $file->isValid()) { + if (! $this->isSupportedUpload($file)) { continue; } + $disk = (string) config('helpdesk.disk', 'public'); - - $originalName = $file->getClientOriginalName(); - $mimeType = $file->getClientMimeType(); - $size = (int) ($file->getSize() ?: 0); - + $originalName = $this->originalName($file); + $mimeType = $this->mimeType($file); + $size = $this->size($file); $path = $file->store('tickets/' . $ticket->id, $disk); - if ($size === 0 && $path !== false) { + if (! is_string($path) || $path === '') { + continue; + } + + if ($size === 0) { $size = (int) Storage::disk($disk)->size($path); } @@ -51,4 +64,40 @@ final class AttachmentService Storage::disk($attachment->disk)->delete($attachment->path); $attachment->delete(); } + + /** + * Determine whether the value is a storeable uploaded file. + */ + private function isSupportedUpload(mixed $file): bool + { + if ($file instanceof TemporaryUploadedFile) { + return true; + } + + return $file instanceof UploadedFile && $file->isValid(); + } + + /** + * Return the uploaded file's original client filename. + */ + private function originalName(mixed $file): string + { + return (string) $file->getClientOriginalName(); + } + + /** + * Return the uploaded file's reported mime type. + */ + private function mimeType(mixed $file): string + { + return (string) ($file->getClientMimeType() ?: $file->getMimeType() ?: 'application/octet-stream'); + } + + /** + * Return the uploaded file's byte size. + */ + private function size(mixed $file): int + { + return (int) ($file->getSize() ?: 0); + } } diff --git a/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php index fb05d998..6ee4833e 100644 --- a/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php +++ b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php @@ -76,6 +76,41 @@ final class GitAndAttachmentTest extends TestCase ]); } + + public function test_existing_ticket_supporting_files_route_accepts_source_code_attachments(): void + { + Storage::fake('local'); + config()->set('helpdesk.disk', 'local'); + + $user = $this->admin(); + $ticket = $this->ticket(['owner' => $user]); + + $this->actingAs($user) + ->post(route('helpdesk.tickets.attachments.store', $ticket), [ + 'attachments' => [ + UploadedFile::fake()->create('TicketStoreController.php', 4, 'text/plain'), + UploadedFile::fake()->create('ticket-editor.js', 4, 'text/plain'), + UploadedFile::fake()->create('warehouse-sync.rpgle', 4, 'text/plain'), + ], + ]) + ->assertRedirect(route('helpdesk.tickets.show', $ticket)); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'TicketStoreController.php', + ]); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'ticket-editor.js', + ]); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'warehouse-sync.rpgle', + ]); + } + public function test_attachment_service_stores_and_deletes_ticket_files(): void { Storage::fake('local'); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 5cdd517c..10d3ac30 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -236,3 +236,31 @@ - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. - Added submit-capture body synchronisation before validation so tickets can be created reliably. - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. + +- version: 1.9.53 + date: 2026-06-24 + type: feature + summary: Allow Helpdesk ticket attachments to include source-code files. + changes: + - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. + - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. + - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. + - Added source-file accept hints to Helpdesk file inputs. + - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. +- version: 1.9.54 + date: 2026-06-24 + changes: + - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. + - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. + - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. + +- version: 1.9.55 + date: 2026-06-24 + type: bugfix + summary: Persist attachments when creating Helpdesk tickets. + changes: + - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. + - Disabled the create button during both attachment upload and ticket save operations. + - 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. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 5d826b68..6ffe8c63 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.52" + "version": "1.9.55" }