new chatgpt module for api intergration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "ChatGPT",
|
||||
"alias": "chatgpt",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.9.52"
|
||||
"version": "1.9.55"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user