new deployer command
This commit is contained in:
@@ -26,40 +26,37 @@ class DeleteOpenAiFilesCommand extends Command
|
||||
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;
|
||||
return $this->executeFor(new OpenAiFileService((string) $setting->api_key));
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function executeFor(OpenAiFileService $service): int
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\ChatGPT\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class OpenAiFileException extends RuntimeException {}
|
||||
@@ -4,7 +4,7 @@ namespace Modules\ChatGPT\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Modules\ChatGPT\Exceptions\OpenAiFileException;
|
||||
|
||||
class OpenAiFileService
|
||||
{
|
||||
@@ -33,7 +33,7 @@ class OpenAiFileService
|
||||
->get(self::BASE_URL . '/files', $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('OpenAI file list request failed: ' . $response->body());
|
||||
throw new OpenAiFileException('OpenAI file list request failed: ' . $response->body());
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
@@ -63,7 +63,7 @@ class OpenAiFileService
|
||||
->delete(self::BASE_URL . '/files/' . rawurlencode($fileId));
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body());
|
||||
throw new OpenAiFileException("OpenAI file delete request failed for {$fileId}: " . $response->body());
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
@@ -90,7 +90,7 @@ class OpenAiFileService
|
||||
try {
|
||||
$this->deleteFile($fileId);
|
||||
$deleted++;
|
||||
} catch (RuntimeException $exception) {
|
||||
} catch (OpenAiFileException $exception) {
|
||||
$failures[] = [
|
||||
'file_id' => $fileId,
|
||||
'error' => $exception->getMessage(),
|
||||
|
||||
@@ -8,6 +8,11 @@ use Modules\Countries\Support\Iso3166;
|
||||
|
||||
class StoreCountryRequest extends FormRequest
|
||||
{
|
||||
private const ISO3 = 'size:3';
|
||||
|
||||
private const MAX_STRING = 'max:255';
|
||||
|
||||
private const MAX_SHORT = 'max:16';
|
||||
/**
|
||||
* Authorize country reference-data administration.
|
||||
*/
|
||||
@@ -38,29 +43,29 @@ class StoreCountryRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'alpha2' => ['required', 'string', 'size:2', 'uppercase', 'unique:countries,alpha2'],
|
||||
'alpha3' => ['required', 'string', 'size:3', 'uppercase', 'unique:countries,alpha3'],
|
||||
'numeric' => ['required', 'string', 'size:3', 'unique:countries,numeric'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'official_name' => ['nullable', 'string', 'max:255'],
|
||||
'common_name' => ['nullable', 'string', 'max:255'],
|
||||
'display_name' => ['nullable', 'string', 'max:255'],
|
||||
'native_name' => ['nullable', 'string', 'max:255'],
|
||||
'capital' => ['nullable', 'string', 'max:255'],
|
||||
'flag_emoji' => ['nullable', 'string', 'max:16'],
|
||||
'calling_code' => ['nullable', 'string', 'max:16'],
|
||||
'tld' => ['nullable', 'string', 'max:16'],
|
||||
'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'],
|
||||
'currency_name' => ['nullable', 'string', 'max:255'],
|
||||
'currency_symbol' => ['nullable', 'string', 'max:16'],
|
||||
'alpha3' => ['required', 'string', self::ISO3, 'uppercase', 'unique:countries,alpha3'],
|
||||
'numeric' => ['required', 'string', self::ISO3, 'unique:countries,numeric'],
|
||||
'name' => ['required', 'string', self::MAX_STRING],
|
||||
'official_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'common_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'display_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'native_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'capital' => ['nullable', 'string', self::MAX_STRING],
|
||||
'flag_emoji' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'calling_code' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'tld' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'currency_code' => ['nullable', 'string', self::ISO3, 'uppercase'],
|
||||
'currency_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'currency_symbol' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'languages' => ['nullable', 'json'],
|
||||
'timezones' => ['nullable', 'json'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'flag_svg_url' => ['nullable', 'url', 'max:255'],
|
||||
'flag_png_url' => ['nullable', 'url', 'max:255'],
|
||||
'flag_svg_url' => ['nullable', 'url', self::MAX_STRING],
|
||||
'flag_png_url' => ['nullable', 'url', self::MAX_STRING],
|
||||
'ioc_code' => ['nullable', 'string', 'max:8'],
|
||||
'fifa_code' => ['nullable', 'string', 'max:8'],
|
||||
'postal_code_format' => ['nullable', 'string', 'max:255'],
|
||||
'postal_code_format' => ['nullable', 'string', self::MAX_STRING],
|
||||
'address_format' => ['nullable', 'string', 'max:5000'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
|
||||
@@ -70,10 +75,10 @@ class StoreCountryRequest extends FormRequest
|
||||
'continent' => ['nullable', 'string', 'max:64'],
|
||||
'region' => ['nullable', 'string', 'max:100'],
|
||||
'subregion' => ['nullable', 'string', 'max:100'],
|
||||
'intermediate_region' => ['nullable', 'string', 'max:255'],
|
||||
'intermediate_region' => ['nullable', 'string', self::MAX_STRING],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
|
||||
'source' => ['nullable', 'string', 'max:255'],
|
||||
'source_version' => ['nullable', 'string', 'max:255'],
|
||||
'source' => ['nullable', 'string', self::MAX_STRING],
|
||||
'source_version' => ['nullable', 'string', self::MAX_STRING],
|
||||
'last_synced_at' => ['nullable', 'date'],
|
||||
'metadata' => ['nullable', 'json'],
|
||||
];
|
||||
|
||||
@@ -8,6 +8,11 @@ use Modules\Countries\Support\Iso3166;
|
||||
|
||||
class UpdateCountryRequest extends FormRequest
|
||||
{
|
||||
private const ISO3 = 'size:3';
|
||||
|
||||
private const MAX_STRING = 'max:255';
|
||||
|
||||
private const MAX_SHORT = 'max:16';
|
||||
/**
|
||||
* Authorize country reference-data administration.
|
||||
*/
|
||||
@@ -40,29 +45,29 @@ class UpdateCountryRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'alpha2' => ['required', 'string', 'size:2', 'uppercase', Rule::unique('countries', 'alpha2')->ignore($countryId)],
|
||||
'alpha3' => ['required', 'string', 'size:3', 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)],
|
||||
'numeric' => ['required', 'string', 'size:3', Rule::unique('countries', 'numeric')->ignore($countryId)],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'official_name' => ['nullable', 'string', 'max:255'],
|
||||
'common_name' => ['nullable', 'string', 'max:255'],
|
||||
'display_name' => ['nullable', 'string', 'max:255'],
|
||||
'native_name' => ['nullable', 'string', 'max:255'],
|
||||
'capital' => ['nullable', 'string', 'max:255'],
|
||||
'flag_emoji' => ['nullable', 'string', 'max:16'],
|
||||
'calling_code' => ['nullable', 'string', 'max:16'],
|
||||
'tld' => ['nullable', 'string', 'max:16'],
|
||||
'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'],
|
||||
'currency_name' => ['nullable', 'string', 'max:255'],
|
||||
'currency_symbol' => ['nullable', 'string', 'max:16'],
|
||||
'alpha3' => ['required', 'string', self::ISO3, 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)],
|
||||
'numeric' => ['required', 'string', self::ISO3, Rule::unique('countries', 'numeric')->ignore($countryId)],
|
||||
'name' => ['required', 'string', self::MAX_STRING],
|
||||
'official_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'common_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'display_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'native_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'capital' => ['nullable', 'string', self::MAX_STRING],
|
||||
'flag_emoji' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'calling_code' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'tld' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'currency_code' => ['nullable', 'string', self::ISO3, 'uppercase'],
|
||||
'currency_name' => ['nullable', 'string', self::MAX_STRING],
|
||||
'currency_symbol' => ['nullable', 'string', self::MAX_SHORT],
|
||||
'languages' => ['nullable', 'json'],
|
||||
'timezones' => ['nullable', 'json'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'flag_svg_url' => ['nullable', 'url', 'max:255'],
|
||||
'flag_png_url' => ['nullable', 'url', 'max:255'],
|
||||
'flag_svg_url' => ['nullable', 'url', self::MAX_STRING],
|
||||
'flag_png_url' => ['nullable', 'url', self::MAX_STRING],
|
||||
'ioc_code' => ['nullable', 'string', 'max:8'],
|
||||
'fifa_code' => ['nullable', 'string', 'max:8'],
|
||||
'postal_code_format' => ['nullable', 'string', 'max:255'],
|
||||
'postal_code_format' => ['nullable', 'string', self::MAX_STRING],
|
||||
'address_format' => ['nullable', 'string', 'max:5000'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
|
||||
@@ -72,10 +77,10 @@ class UpdateCountryRequest extends FormRequest
|
||||
'continent' => ['nullable', 'string', 'max:64'],
|
||||
'region' => ['nullable', 'string', 'max:100'],
|
||||
'subregion' => ['nullable', 'string', 'max:100'],
|
||||
'intermediate_region' => ['nullable', 'string', 'max:255'],
|
||||
'intermediate_region' => ['nullable', 'string', self::MAX_STRING],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
|
||||
'source' => ['nullable', 'string', 'max:255'],
|
||||
'source_version' => ['nullable', 'string', 'max:255'],
|
||||
'source' => ['nullable', 'string', self::MAX_STRING],
|
||||
'source_version' => ['nullable', 'string', self::MAX_STRING],
|
||||
'last_synced_at' => ['nullable', 'date'],
|
||||
'metadata' => ['nullable', 'json'],
|
||||
];
|
||||
|
||||
@@ -5,6 +5,8 @@ use Modules\Countries\Http\Controllers\CountriesController;
|
||||
use Modules\Countries\Http\Controllers\CountrySyncController;
|
||||
use Modules\Countries\Http\Controllers\ReferenceDataController;
|
||||
|
||||
const COUNTRY_ROUTE = COUNTRY_ROUTE;
|
||||
|
||||
Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void {
|
||||
Route::get('/', CountriesController::class)->name('index');
|
||||
Route::get('/create', [CountriesController::class, 'create'])->name('create');
|
||||
@@ -15,8 +17,8 @@ Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->gro
|
||||
Route::get('/reference/languages', [ReferenceDataController::class, 'languages'])->name('languages.index');
|
||||
Route::get('/reference/scripts', [ReferenceDataController::class, 'scripts'])->name('scripts.index');
|
||||
Route::get('/reference/locodes', [ReferenceDataController::class, 'locodes'])->name('locodes.index');
|
||||
Route::get('/{country:alpha2}', [CountriesController::class, 'show'])->name('show');
|
||||
Route::get('/{country:alpha2}/edit', [CountriesController::class, 'edit'])->name('edit');
|
||||
Route::put('/{country:alpha2}', [CountriesController::class, 'update'])->name('update');
|
||||
Route::delete('/{country:alpha2}', [CountriesController::class, 'destroy'])->name('destroy');
|
||||
Route::get(COUNTRY_ROUTE, [CountriesController::class, 'show'])->name('show');
|
||||
Route::get(COUNTRY_ROUTE . '/edit', [CountriesController::class, 'edit'])->name('edit');
|
||||
Route::put(COUNTRY_ROUTE, [CountriesController::class, 'update'])->name('update');
|
||||
Route::delete(COUNTRY_ROUTE, [CountriesController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ use Modules\Documentation\Models\DocumentationDocument;
|
||||
|
||||
class DocumentationController extends Controller
|
||||
{
|
||||
private const string STORAGE_DISK_CONFIG = 'documentation.storage_disk';
|
||||
|
||||
private const string UPLOAD_PATH_CONFIG = 'documentation.upload_path';
|
||||
/**
|
||||
* Display the documentation dashboard and document list.
|
||||
*/
|
||||
@@ -170,7 +173,7 @@ class DocumentationController extends Controller
|
||||
{
|
||||
abort_if(blank($document->attachment_path), 404);
|
||||
|
||||
return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))
|
||||
return Storage::disk($document->attachment_disk ?: config(self::STORAGE_DISK_CONFIG, 'public'))
|
||||
->download($document->attachment_path, $document->attachment_name);
|
||||
}
|
||||
|
||||
@@ -202,27 +205,19 @@ class DocumentationController extends Controller
|
||||
*/
|
||||
private function extractMarkdownBody(Request $request): ?string
|
||||
{
|
||||
if (! config('documentation.import_markdown_attachments', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = $request->file('attachment');
|
||||
$canImport = config('documentation.import_markdown_attachments', true)
|
||||
&& $file !== null
|
||||
&& $this->isMarkdownUpload($request)
|
||||
&& $file->getSize() <= (int) config('documentation.max_markdown_import_bytes', 1048576);
|
||||
|
||||
if ($file === null || ! $this->isMarkdownUpload($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) {
|
||||
if (! $canImport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
return $content === false ? null : trim($content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,8 +245,8 @@ class DocumentationController extends Controller
|
||||
private function storeAttachment(Request $request): array
|
||||
{
|
||||
$file = $request->file('attachment');
|
||||
$disk = config('documentation.storage_disk', 'public');
|
||||
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
||||
$disk = config(self::STORAGE_DISK_CONFIG, 'public');
|
||||
$path = $file->store(config(self::UPLOAD_PATH_CONFIG, 'documentation'), $disk);
|
||||
|
||||
return [
|
||||
'attachment_disk' => $disk,
|
||||
@@ -271,14 +266,14 @@ class DocumentationController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = config('documentation.storage_disk', 'public');
|
||||
$disk = config(self::STORAGE_DISK_CONFIG, 'public');
|
||||
|
||||
foreach ((array) $request->file('attachments') as $file) {
|
||||
if ($file === null || ! $file->isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
||||
$path = $file->store(config(self::UPLOAD_PATH_CONFIG, 'documentation'), $disk);
|
||||
|
||||
$document->attachments()->create([
|
||||
'disk' => $disk,
|
||||
@@ -297,7 +292,7 @@ class DocumentationController extends Controller
|
||||
private function deleteAttachment(DocumentationDocument $document): void
|
||||
{
|
||||
if (filled($document->attachment_path)) {
|
||||
Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path);
|
||||
Storage::disk($document->attachment_disk ?: config(self::STORAGE_DISK_CONFIG, 'public'))->delete($document->attachment_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Route;
|
||||
use Modules\Documentation\Http\Controllers\DocumentationCategoryController;
|
||||
use Modules\Documentation\Http\Controllers\DocumentationController;
|
||||
|
||||
const DOCUMENT_ROUTE = DOCUMENT_ROUTE;
|
||||
|
||||
Route::middleware(['web', 'auth'])
|
||||
->prefix('documentation')
|
||||
->name('documentation.')
|
||||
@@ -15,13 +17,13 @@ Route::middleware(['web', 'auth'])
|
||||
Route::get('/documents', [DocumentationController::class, 'index'])->name('documents.index');
|
||||
Route::get('/documents/create', [DocumentationController::class, 'create'])->name('documents.create');
|
||||
Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store');
|
||||
Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show');
|
||||
Route::get('/documents/{document}/edit', [DocumentationController::class, 'edit'])->name('documents.edit');
|
||||
Route::put('/documents/{document}', [DocumentationController::class, 'update'])->name('documents.update');
|
||||
Route::delete('/documents/{document}', [DocumentationController::class, 'destroy'])->name('documents.destroy');
|
||||
Route::get('/documents/{document}/download', [DocumentationController::class, 'download'])->name('documents.download');
|
||||
Route::get('/documents/{document}/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download');
|
||||
Route::delete('/documents/{document}/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy');
|
||||
Route::get(DOCUMENT_ROUTE, [DocumentationController::class, 'show'])->name('documents.show');
|
||||
Route::get(DOCUMENT_ROUTE . '/edit', [DocumentationController::class, 'edit'])->name('documents.edit');
|
||||
Route::put(DOCUMENT_ROUTE, [DocumentationController::class, 'update'])->name('documents.update');
|
||||
Route::delete(DOCUMENT_ROUTE, [DocumentationController::class, 'destroy'])->name('documents.destroy');
|
||||
Route::get(DOCUMENT_ROUTE . '/download', [DocumentationController::class, 'download'])->name('documents.download');
|
||||
Route::get(DOCUMENT_ROUTE . '/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download');
|
||||
Route::delete(DOCUMENT_ROUTE . '/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy');
|
||||
|
||||
Route::resource('categories', DocumentationCategoryController::class)->except(['show']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class TicketPersistenceException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -18,6 +18,10 @@ final class GitWebhookController
|
||||
private const REJECTED = 'rejected';
|
||||
|
||||
private const SIGNATURE_FAILED = 'Git webhook signature or token validation failed.';
|
||||
|
||||
private const SHA256_PREFIX = 'sha256=';
|
||||
|
||||
private const SHA1_PREFIX = 'sha1=';
|
||||
/**
|
||||
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
|
||||
*/
|
||||
@@ -137,8 +141,8 @@ final class GitWebhookController
|
||||
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
|
||||
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
||||
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
||||
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +156,8 @@ final class GitWebhookController
|
||||
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
|
||||
|
||||
return hash_equals($sha256, $giteaSignature)
|
||||
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|
||||
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|
||||
|| hash_equals(self::SHA256_PREFIX . $sha256, $giteaSignature)
|
||||
|| hash_equals(self::SHA1_PREFIX . $sha1, $giteaSignature)
|
||||
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|
||||
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
|
||||
}
|
||||
@@ -165,7 +169,7 @@ final class GitWebhookController
|
||||
{
|
||||
$signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
|
||||
}
|
||||
|
||||
@@ -178,8 +182,8 @@ final class GitWebhookController
|
||||
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
|
||||
|
||||
return hash_equals($secret, $token)
|
||||
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
||||
|| hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Exceptions\TicketPersistenceException;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
@@ -68,7 +69,7 @@ final class TicketBoardController extends Controller
|
||||
->update($updates);
|
||||
|
||||
if ($affected < 1) {
|
||||
throw new \RuntimeException('Ticket board update did not persist.');
|
||||
throw new TicketPersistenceException('Ticket board update did not persist.');
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
|
||||
@@ -114,23 +114,13 @@ final class AttachmentService
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
if (str_starts_with($mimeType, 'image/') || in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if ($extension === 'pdf' || $mimeType === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true)) {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
if (in_array($extension, config('helpdesk.allowed_source_extensions', []), true)) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
return match (true) {
|
||||
str_starts_with($mimeType, 'image/'), in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true) => 'image',
|
||||
$extension === 'pdf', $mimeType === 'application/pdf' => 'pdf',
|
||||
in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true) => 'archive',
|
||||
in_array($extension, config('helpdesk.allowed_source_extensions', []), true) => 'source',
|
||||
default => 'file',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Exceptions\TicketPersistenceException;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
@@ -45,7 +46,7 @@ final class TicketService
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unable to create a unique Helpdesk ticket reference.');
|
||||
throw new TicketPersistenceException('Unable to create a unique Helpdesk ticket reference.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,32 +57,8 @@ final class TicketService
|
||||
*/
|
||||
private function createInsideTransaction(User $user, array $data, array $files = []): Ticket
|
||||
{
|
||||
$defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new');
|
||||
$status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default')
|
||||
? Status::query()->where('is_default', true)->orderBy('sort_order')->first()
|
||||
: null;
|
||||
|
||||
if (! $status) {
|
||||
$status = Status::query()->where('slug', $defaultStatusSlug)->first();
|
||||
}
|
||||
|
||||
if (! $status) {
|
||||
$status = Status::query()->create([
|
||||
'name' => 'New',
|
||||
'slug' => $defaultStatusSlug,
|
||||
'color' => 'sky',
|
||||
'sort_order' => 1,
|
||||
'is_closed' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$boardId = $data['board_id'] ?? null;
|
||||
if (blank($boardId) && Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
$boardId = KanbanBoard::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id');
|
||||
}
|
||||
$status = $this->resolveInitialStatus();
|
||||
$boardId = $this->resolveBoardId($data['board_id'] ?? null);
|
||||
|
||||
/** @var Ticket $ticket */
|
||||
$ticket = Ticket::query()->create([
|
||||
@@ -102,9 +79,7 @@ final class TicketService
|
||||
'last_activity_at' => now(),
|
||||
]);
|
||||
|
||||
if (! empty($data['tag_ids']) && is_array($data['tag_ids'])) {
|
||||
$ticket->tags()->sync(array_values(array_unique(array_map('intval', $data['tag_ids']))));
|
||||
}
|
||||
$this->syncTags($ticket, $data['tag_ids'] ?? null);
|
||||
|
||||
$ticket = $this->automation->apply($ticket, $data);
|
||||
$ticket = $this->sla->applyPolicy($ticket);
|
||||
@@ -117,6 +92,43 @@ final class TicketService
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
private function resolveInitialStatus(): Status
|
||||
{
|
||||
$defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new');
|
||||
$status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default')
|
||||
? Status::query()->where('is_default', true)->orderBy('sort_order')->first()
|
||||
: null;
|
||||
|
||||
$status ??= Status::query()->where('slug', $defaultStatusSlug)->first();
|
||||
|
||||
return $status ?? Status::query()->create([
|
||||
'name' => 'New',
|
||||
'slug' => $defaultStatusSlug,
|
||||
'color' => 'sky',
|
||||
'sort_order' => 1,
|
||||
'is_closed' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveBoardId(mixed $boardId): mixed
|
||||
{
|
||||
if (! blank($boardId) || ! Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
return $boardId;
|
||||
}
|
||||
|
||||
return KanbanBoard::query()->where('is_default', true)->orderBy('sort_order')->value('id')
|
||||
?: KanbanBoard::query()->orderBy('sort_order')->value('id');
|
||||
}
|
||||
|
||||
private function syncTags(Ticket $ticket, mixed $tagIds): void
|
||||
{
|
||||
if (! is_array($tagIds) || $tagIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket->tags()->sync(array_values(array_unique(array_map('intval', $tagIds))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a database error was caused by the ticket reference unique index.
|
||||
*/
|
||||
@@ -137,52 +149,68 @@ final class TicketService
|
||||
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null, ?string $subject = null): Comment
|
||||
{
|
||||
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment {
|
||||
$parent = $parentId === null
|
||||
? null
|
||||
: Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->findOrFail($parentId);
|
||||
|
||||
$guid = (string) Str::uuid();
|
||||
$unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true)));
|
||||
$parentUnid = $parent?->unid;
|
||||
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
|
||||
$plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
'body_text' => $plainTextBody,
|
||||
'is_internal' => $internal,
|
||||
'guid' => $guid,
|
||||
'unid' => $unid,
|
||||
'parent_unid' => $parentUnid,
|
||||
'root_unid' => $rootUnid,
|
||||
'originator_id' => $unid . ':1',
|
||||
'sync_sequence' => 1,
|
||||
'sync_hash' => hash('sha256', $body),
|
||||
'source_system' => 'wms-helpdesk',
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
|
||||
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
|
||||
}
|
||||
$parent = $this->resolveParentComment($ticket, $parentId);
|
||||
$payload = $this->commentPayload($ticket, $user, $body, $internal, $parent, $parentId, $subject);
|
||||
|
||||
/** @var Comment $comment */
|
||||
$comment = $ticket->comments()->create($payload);
|
||||
|
||||
$ticket->forceFill(['last_activity_at' => now()])->save();
|
||||
if (! $internal) {
|
||||
$this->sla->recordPublicResponse($ticket, $user);
|
||||
}
|
||||
$this->attachments->storeMany($ticket, $files, (int) $user->id, $comment);
|
||||
$this->workflow->dispatch($ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket, $parentId === null ? 'ticket.commented' : 'ticket.reply_created', $user);
|
||||
$this->afterCommentCreated($ticket, $user, $comment, $files, $internal, $parentId);
|
||||
|
||||
return $comment->fresh(['user', 'attachments']) ?? $comment;
|
||||
});
|
||||
}
|
||||
|
||||
private function resolveParentComment(Ticket $ticket, ?int $parentId): ?Comment
|
||||
{
|
||||
return $parentId === null
|
||||
? null
|
||||
: Comment::query()->where('ticket_id', $ticket->id)->findOrFail($parentId);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function commentPayload(Ticket $ticket, User $user, string $body, bool $internal, ?Comment $parent, ?int $parentId, ?string $subject): array
|
||||
{
|
||||
$guid = (string) Str::uuid();
|
||||
$unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true)));
|
||||
$parentUnid = $parent?->unid;
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
'body_text' => trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''),
|
||||
'is_internal' => $internal,
|
||||
'guid' => $guid,
|
||||
'unid' => $unid,
|
||||
'parent_unid' => $parentUnid,
|
||||
'root_unid' => $parent?->root_unid ?: $parentUnid ?: $unid,
|
||||
'originator_id' => $unid . ':1',
|
||||
'sync_sequence' => 1,
|
||||
'sync_hash' => hash('sha256', $body),
|
||||
'source_system' => 'wms-helpdesk',
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
|
||||
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** @param array<int, UploadedFile> $files */
|
||||
private function afterCommentCreated(Ticket $ticket, User $user, Comment $comment, array $files, bool $internal, ?int $parentId): void
|
||||
{
|
||||
$ticket->forceFill(['last_activity_at' => now()])->save();
|
||||
|
||||
if (! $internal) {
|
||||
$this->sla->recordPublicResponse($ticket, $user);
|
||||
}
|
||||
|
||||
$this->attachments->storeMany($ticket, $files, (int) $user->id, $comment);
|
||||
$event = $parentId === null ? 'ticket.commented' : 'ticket.reply_created';
|
||||
$freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket;
|
||||
$this->workflow->dispatch($freshTicket, $event, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a ticket.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user