new chatgpt module for api intergration

This commit is contained in:
Mark Gregory
2026-06-24 09:56:13 +01:00
parent c5335dc635
commit a42b5787b9
28 changed files with 1028 additions and 107 deletions
@@ -0,0 +1,63 @@
<?php
namespace Modules\ChatGPT\Console;
use Illuminate\Console\Command;
use Modules\ChatGPT\Models\ChatGptApiSetting;
use Modules\ChatGPT\Services\OpenAiFileService;
use Throwable;
class DeleteOpenAiFilesCommand extends Command
{
protected $signature = 'chatgpt:delete-api-files {--dry-run : List files without deleting them}';
protected $description = 'Delete all OpenAI API files visible to the stored active API key.';
/**
* Execute the console command.
*/
public function handle(): int
{
$setting = ChatGptApiSetting::active();
if (! $setting) {
$this->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;
}
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chatgpt_api_settings', function (Blueprint $table): void {
$table->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');
}
};
@@ -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.');
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\ChatGPT\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreChatGptApiSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'api_key' => ['required', 'string', 'min:20', 'max:500'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\ChatGPT\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateChatGptApiSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'api_key' => ['nullable', 'string', 'min:20', 'max:500'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\ChatGPT\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ChatGptApiSetting extends Model
{
use HasFactory;
protected $table = 'chatgpt_api_settings';
protected $fillable = [
'name',
'api_key',
'is_active',
];
protected $casts = [
'api_key' => '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);
}
}
+166 -4
View File
@@ -1,9 +1,171 @@
@extends('chatgpt::layouts.master')
@section('content')
<h1>Hello World</h1>
<section class="mx-auto max-w-5xl space-y-6 p-6">
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">ChatGPT API Settings</h1>
<p class="mt-1 text-sm text-slate-600">
Store the OpenAI API key used by the module command. Keys are encrypted at rest by Laravel.
</p>
</div>
<p>
This view is loaded from module: {!! config('chatgpt.name') !!}
</p>
<code class="rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700">
php artisan chatgpt:delete-api-files
</code>
</div>
</div>
@if (session('status'))
<div class="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-800">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
<strong>Unable to save the API key.</strong>
<ul class="mt-2 list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Create API Key</h2>
<form method="POST" action="{{ route('chatgpt.settings.store') }}" class="mt-5 grid gap-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-slate-700">Name</label>
<input
id="name"
name="name"
type="text"
value="{{ old('name', 'OpenAI API Key') }}"
class="mt-1 w-full rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label for="api_key" class="block text-sm font-medium text-slate-700">API Key</label>
<input
id="api_key"
name="api_key"
type="password"
value=""
autocomplete="new-password"
required
class="mt-1 w-full rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" checked class="rounded border-slate-300 text-blue-600 focus:ring-blue-500">
Make active key
</label>
<div>
<button type="submit" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save API Key
</button>
</div>
</form>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Stored API Keys</h2>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Key</th>
<th class="px-4 py-3">Active</th>
<th class="px-4 py-3">Updated</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
@forelse ($settings as $setting)
<tr>
<td class="px-4 py-4 align-top font-medium text-slate-900">
<form id="setting-update-{{ $setting->id }}" method="POST" action="{{ route('chatgpt.settings.update', $setting) }}" class="grid gap-3">
@csrf
@method('PUT')
<input
name="name"
type="text"
value="{{ old('name', $setting->name) }}"
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<input
name="api_key"
type="password"
value=""
autocomplete="new-password"
placeholder="Leave blank to keep existing key"
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<label class="inline-flex items-center gap-2 text-xs text-slate-600">
<input
type="checkbox"
name="is_active"
value="1"
@checked($setting->is_active)
class="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
>
Active
</label>
</form>
</td>
<td class="px-4 py-4 align-top font-mono text-slate-700">{{ $setting->maskedApiKey() }}</td>
<td class="px-4 py-4 align-top">
@if ($setting->is_active)
<span class="rounded-full bg-green-100 px-2.5 py-1 text-xs font-semibold text-green-800">Yes</span>
@else
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">No</span>
@endif
</td>
<td class="px-4 py-4 align-top text-slate-600">{{ $setting->updated_at?->format('d M Y H:i') }}</td>
<td class="px-4 py-4 align-top">
<div class="flex justify-end gap-2">
<button
type="submit"
form="setting-update-{{ $setting->id }}"
class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-700"
>
Update
</button>
<form method="POST" action="{{ route('chatgpt.settings.destroy', $setting) }}">
@csrf
@method('DELETE')
<button type="submit" class="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white hover:bg-red-700">
Delete
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-slate-500">
No API keys have been stored yet.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</section>
@endsection
+11 -13
View File
@@ -1,16 +1,14 @@
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
use Illuminate\Support\Facades\Route;
use Modules\ChatGPT\Http\Controllers\ChatGPTController;
Route::prefix('chatgpt')->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');
});
@@ -0,0 +1,121 @@
<?php
namespace Modules\ChatGPT\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiFileService
{
private const BASE_URL = 'https://api.openai.com/v1';
public function __construct(private readonly string $apiKey)
{
}
/**
* List all OpenAI API files visible to the configured API key.
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<int, array{file_id:string, error:string}>}
*/
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);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\ChatGPT\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\ChatGPT\Models\ChatGptApiSetting;
use Tests\TestCase;
class ChatGptApiSettingTest extends TestCase
{
use RefreshDatabase;
/**
* It encrypts stored API keys at rest.
*/
public function test_api_key_is_encrypted_at_rest(): void
{
$setting = ChatGptApiSetting::query()->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()));
}
}
+8
View File
@@ -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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ChatGPT",
"alias": "chatgpt",
"version": "1.0.0",
"version": "1.1.0",
"description": "",
"keywords": [],
"priority": 0,
+28
View File
@@ -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`.
+10 -1
View File
@@ -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',
];
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
+34 -5
View File
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
+34 -1
View File
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
+35 -5
View File
@@ -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<int, string>
*/
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<int, string>
*/
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();
}
}
@@ -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
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
@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.',
])
<form wire:submit.prevent="save" x-on:submit.capture="$dispatch('helpdesk-editor-sync', { model: 'body' })" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6">
<form
wire:submit.prevent="save"
x-data="{ helpdeskAttachmentsUploading: false }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
x-on:submit.capture="
if (helpdeskAttachmentsUploading) {
$event.preventDefault();
return;
}
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
enctype="multipart/form-data"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
@@ -254,7 +278,7 @@
</label>
</div>
<input id="attachments" type="file" wire:model="attachments" multiple class="sr-only">
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only">
<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">
@@ -289,9 +313,10 @@
<button type="submit"
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save" wire:dirty.class="ring-2 ring-amber-400/40">
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save">Create Ticket</span>
<span wire:loading.remove wire:target="save,attachments">Create Ticket</span>
<span wire:loading wire:target="attachments">Uploading...</span>
<span wire:loading wire:target="save">Creating...</span>
</button>
</div>
@@ -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
<div class="space-y-6">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('helpdesk::partials.page-header', [
@@ -323,7 +330,7 @@
<div class="p-4">
<input id="attachments" type="file" wire:model="attachments" multiple
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}"
class="sr-only">
<div wire:loading.flex wire:target="attachments"
@@ -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
@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">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Choose files</span>
<input name="attachments[]" type="file" multiple class="sr-only"
<input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input>
</label>
@@ -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">
<i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i>
<span>Choose files</span>
<input name="comment_attachments[]" type="file" multiple class="sr-only"
<input name="comment_attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input>
</label>
</div>
@@ -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])
<div class="space-y-3" data-helpdesk-thread-level="{{ (int) $level }}">
@@ -285,7 +292,7 @@
<label data-helpdesk-file-button>
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Attach files</span>
<input name="attachments[]" type="file" multiple class="sr-only"
<input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input>
</label>
</div>
@@ -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<int|string, mixed> $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);
}
}
@@ -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');
+28
View File
@@ -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.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.52"
"version": "1.9.55"
}