File manager - Edit - /var/www/ratemypay_dev/app/Http/Controllers/JDWizardController.php
Back
<?php namespace App\Http\Controllers; use App\Models\JobDescription; use App\Models\JobDescriptionActivityLog; use App\Models\JobDescriptionComment; use App\Models\JobDescriptionExport; use App\Models\JobDescriptionSection; use App\Models\JobDescriptionValidationCheck; use App\Models\JobDescriptionVersion; use App\Services\JDWizardAIService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\Rule; class JDWizardController extends Controller { public function __construct(private readonly JDWizardAIService $aiService) {} // Fetch all JD in the dashboard with cards public function index(Request $request): JsonResponse{ $user = Auth::user(); $query = JobDescription::with(['creator', 'approver', 'publisher', 'organization']) ->withCount('sections'); // Scope: org users see all JDs in their org; regular users see only their own if ($user->organization_id) { $query->where('organization_id', $user->organization_id); } else { $query->where('created_by', $user->id); } // Filters if ($request->filled('search')) { $search = $request->input('search'); $query->where(function ($q) use ($search) { $q->where('job_title', 'like', "%{$search}%") ->orWhere('department', 'like', "%{$search}%") ->orWhere('level', 'like', "%{$search}%"); }); } if ($request->filled('department')) { $query->where('department', $request->input('department')); } if ($request->filled('status')) { $query->where('status', $request->input('status')); } // Counts for cards $baseQuery = clone $query; $counts = [ 'total' => (clone $baseQuery)->count(), 'approved' => (clone $baseQuery)->where('status', JobDescription::STATUS_APPROVED)->count(), 'published' => (clone $baseQuery)->where('status', JobDescription::STATUS_PUBLISHED)->count(), 'draft' => (clone $baseQuery)->where('status', JobDescription::STATUS_DRAFT)->count(), ]; // Pagination $perPage = $request->input('per_page', 15); $perPage = is_numeric($perPage) ? min(max((int) $perPage, 5), 100) : 15; $jobDescriptions = $query->latest()->paginate($perPage)->withQueryString(); // Department list for the filter dropdown $departments = JobDescription::where('created_by', $user->id) ->when($user->organization_id, fn ($q) => $q->orWhere('organization_id', $user->organization_id)) ->whereNotNull('department') ->distinct() ->pluck('department') ->sort() ->values(); return response()->json([ 'success' => true, 'counts' => $counts, 'job_descriptions' => $jobDescriptions, 'departments' => $departments, ]); } // Create JD (AI Generattion) public function create(Request $request): JsonResponse{ $validated = $request->validate([ 'job_title' => 'required|string|max:255', 'department' => 'required|string|max:255', 'level' => 'required|string|max:255', 'location' => 'required|string|max:255', 'employment_type' => 'required|string|max:100', ]); try { $result = DB::transaction(function () use ($validated) { $jd = JobDescription::create([ 'organization_id' => Auth::user()->organization_id, // nullable 'job_title' => $validated['job_title'], 'department' => $validated['department'], 'level' => $validated['level'], 'location' => $validated['location'], 'employment_type' => $validated['employment_type'], 'status' => JobDescription::STATUS_DRAFT, 'created_by' => Auth::id(), ]); $this->log($jd, JobDescriptionActivityLog::EVENT_DRAFT_CREATED); // Generate AI sections $aiSections = $this->aiService->generateJD($jd); foreach (JobDescriptionSection::SECTION_TYPES as $index => $sectionType) { JobDescriptionSection::create([ 'job_description_id' => $jd->id, 'section_type' => $sectionType, 'title' => null, 'content' => $aiSections[$sectionType], 'source' => JobDescriptionSection::SOURCE_AI, 'version' => 1, 'is_core' => true, 'sort_order' => $index, ]); } $this->log($jd, JobDescriptionActivityLog::EVENT_AI_GENERATION_COMPLETE); // Save initial version snapshot JobDescriptionVersion::create([ 'job_description_id' => $jd->id, 'version_number' => 1, 'snapshot_json' => $jd->fresh()->toSnapshot(), 'created_by' => Auth::id(), ]); return $jd->load('sections'); }); return response()->json([ 'success' => true, 'message' => 'Job description created and AI draft generated.', 'job_description' => $result, 'sections' => $result->sections->values(), ], 201); } catch (\Throwable $e) { Log::error('[JDWizardController] create failed', ['error' => $e->getMessage()]); return response()->json([ 'success' => false, 'message' => 'Failed to create job description. Please try again.', ], 500); } } // Manual section edit public function updateSection(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); $this->assertEditable($jobDescription); $validated = $request->validate([ 'section_type' => ['required', Rule::in(JobDescriptionSection::SECTION_TYPES)], 'content' => 'required|string|min:10', ]); try { $section = DB::transaction(function () use ($validated, $jobDescription) { $section = JobDescriptionSection::updateOrCreate( [ 'job_description_id' => $jobDescription->id, 'section_type' => $validated['section_type'], ], [ 'content' => $validated['content'], 'source' => JobDescriptionSection::SOURCE_USER, 'version' => DB::raw('version + 1'), 'is_core' => true, 'sort_order' => array_search( $validated['section_type'], JobDescriptionSection::SECTION_TYPES ), ] ); $this->saveVersion($jobDescription); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_SECTION_EDITED, [ 'section_type' => $validated['section_type'], ]); return $section->fresh(); }); return response()->json([ 'success' => true, 'message' => 'Section updated.', 'section' => $section, ]); } catch (\Throwable $e) { Log::error('[JDWizardController] updateSection failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'message' => 'Failed to update section.'], 500); } } // AI regeneration of a single section public function regenerateSection(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); $this->assertEditable($jobDescription); $validated = $request->validate([ 'section_type' => ['required', Rule::in(JobDescriptionSection::SECTION_TYPES)], 'instruction' => 'nullable|string', ]); try { $newContent = $this->aiService->regenerateSection( $jobDescription, $validated['section_type'], $validated['instruction'] ?? '' ); $section = DB::transaction(function () use ($newContent, $validated, $jobDescription) { $section = JobDescriptionSection::updateOrCreate( [ 'job_description_id' => $jobDescription->id, 'section_type' => $validated['section_type'], ], [ 'content' => $newContent, 'source' => JobDescriptionSection::SOURCE_AI, 'version' => DB::raw('version + 1'), 'is_core' => true, 'sort_order' => array_search( $validated['section_type'], JobDescriptionSection::SECTION_TYPES ), ] ); $this->saveVersion($jobDescription); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_SECTION_REGENERATED, [ 'section_type' => $validated['section_type'], ]); return $section->fresh(); }); return response()->json([ 'success' => true, 'message' => 'Section regenerated.', 'section' => $section, ]); } catch (\Throwable $e) { Log::error('[JDWizardController] regenerateSection failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'message' => 'Regeneration failed. Please try again.'], 500); } } // Add a custom section public function addCustomSection(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); $this->assertEditable($jobDescription); $validated = $request->validate([ 'title' => 'required|string|max:255', 'content' => 'required|string|min:5', ]); try { $section = DB::transaction(function () use ($validated, $jobDescription) { // Custom sections are appended after all core sections $maxOrder = $jobDescription->sections()->max('sort_order') ?? 4; $section = JobDescriptionSection::create([ 'job_description_id' => $jobDescription->id, 'section_type' => null, 'title' => $validated['title'], 'content' => $validated['content'], 'source' => JobDescriptionSection::SOURCE_USER, 'version' => 1, 'is_core' => false, 'sort_order' => $maxOrder + 1, ]); $this->saveVersion($jobDescription); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_CUSTOM_SECTION_ADDED, [ 'title' => $validated['title'], ]); return $section; }); return response()->json([ 'success' => true, 'message' => 'Custom section added.', 'section' => $section, ], 201); } catch (\Throwable $e) { Log::error('[JDWizardController] addCustomSection failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'message' => 'Failed to add custom section.'], 500); } } // Only custom sections can be deleted public function deleteCustomSection( JobDescription $jobDescription, JobDescriptionSection $section ): JsonResponse { $this->authorizeJD($jobDescription); $this->assertEditable($jobDescription); if ($section->job_description_id !== $jobDescription->id) { return response()->json(['success' => false, 'message' => 'Section not found.'], 404); } if ($section->isCore()) { return response()->json([ 'success' => false, 'message' => 'Core sections cannot be deleted.', ], 422); } DB::transaction(function () use ($jobDescription, $section) { $title = $section->title; $section->delete(); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_CUSTOM_SECTION_DELETED, [ 'title' => $title, ]); }); return response()->json(['success' => true, 'message' => 'Custom section deleted.']); } // Runs all 14 checks via AI, persists each result public function validation(JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); try { $aiResult = $this->aiService->validateJD($jobDescription); DB::transaction(function () use ($aiResult, $jobDescription) { // Replace all previous check rows for this JD $jobDescription->validationChecks()->delete(); foreach ($aiResult['checks'] as $check) { JobDescriptionValidationCheck::create([ 'job_description_id' => $jobDescription->id, 'section_type' => $check['section_type'] ?? JobDescriptionSection::sectionTypeForCheck($check['check_key']), 'check_key' => $check['check_key'], 'status' => $check['status'], 'score' => $check['score'] ?? 0, 'passed' => $check['passed'], 'message' => $check['message'] ?? null, ]); } // Log the run $this->log($jobDescription, JobDescriptionActivityLog::EVENT_VALIDATION_RUN); $overallPassed = $aiResult['passed'] ?? false; $this->log( $jobDescription, $overallPassed ? JobDescriptionActivityLog::EVENT_VALIDATION_PASSED : JobDescriptionActivityLog::EVENT_VALIDATION_FAILED, ['score' => $aiResult['score'] ?? null] ); }); // Build summary cards $checks = $jobDescription->validationChecks()->get(); $totalChecks = $checks->count(); $totalAligned = $checks->where('passed', true)->count(); $totalReview = $checks->where('passed', false)->count(); // Per-section passed status // A section is passed only if ALL its checks passed $sectionStatus = []; foreach (JobDescriptionSection::SECTION_TYPES as $sectionType) { $sectionChecks = $checks->where('section_type', $sectionType); $sectionStatus[$sectionType] = $sectionChecks->isNotEmpty() && $sectionChecks->where('passed', false)->isEmpty(); } return response()->json([ 'success' => true, 'overall_passed' => $aiResult['passed'] ?? false, 'overall_score' => $aiResult['score'] ?? 0, 'suggestions' => $aiResult['suggestions'] ?? [], 'cards' => [ 'total_checks' => $totalChecks, 'total_aligned' => $totalAligned, 'total_needs_review' => $totalReview, ], 'section_status' => $sectionStatus, 'checks' => $checks->map(fn ($c) => [ 'id' => $c->id, 'check_key' => $c->check_key, 'label' => $c->label(), 'section_type' => $c->section_type, 'status' => $c->status, 'score' => $c->score, 'passed' => $c->passed, 'message' => $c->message, ])->values(), // Labels for the front-end to render without hardcoding 'check_labels' => JobDescriptionValidationCheck::CHECK_LABELS, ]); } catch (\Throwable $e) { Log::error('[JDWizardController] validation failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'message' => 'Validation failed. Please try again.'], 500); } } // Saves/keeps as draft. Also allows reverting an approved JD back to draft. public function saveDraft(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); if ($jobDescription->isPublished()) { return response()->json([ 'success' => false, 'message' => 'Published job descriptions cannot be reverted to draft.', ], 422); } $jobDescription->update([ 'status' => JobDescription::STATUS_DRAFT, 'approved_by' => null, 'approved_at' => null, ]); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_SAVED_AS_DRAFT); return response()->json(['success' => true, 'message' => 'Job description saved as draft.']); } // Approves the JD public function approve(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); if ($jobDescription->isPublished()) { return response()->json([ 'success' => false, 'message' => 'Published job descriptions cannot be re-approved.', ], 422); } if (!$jobDescription->isDraft()) { return response()->json([ 'success' => false, 'message' => 'Only draft job descriptions can be approved.', ], 422); } if (!$jobDescription->isComplete()) { return response()->json([ 'success' => false, 'message' => 'All core sections must be present before approving.', ], 422); } $validated = $request->validate([ 'comment' => 'nullable|string|max:1000', ]); DB::transaction(function () use ($validated, $jobDescription) { $jobDescription->update([ 'status' => JobDescription::STATUS_APPROVED, 'approved_by' => Auth::id(), 'approved_at' => now(), ]); if (!empty($validated['comment'])) { JobDescriptionComment::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'comment' => $validated['comment'], ]); } $this->log($jobDescription, JobDescriptionActivityLog::EVENT_APPROVED); }); return response()->json(['success' => true, 'message' => 'Job description approved.']); } // Sends a pending JD back to draft with a reason comment public function sendBack(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeAdmin(); $this->authorizeJD($jobDescription); if (!$jobDescription->isApproved()) { return response()->json([ 'success' => false, 'message' => 'Only approved job descriptions can be sent back.', ], 422); } $validated = $request->validate([ 'reason' => 'nullable|string|max:1000', ]); DB::transaction(function () use ($validated, $jobDescription) { $jobDescription->update([ 'status' => JobDescription::STATUS_DRAFT, 'approved_by' => null, 'approved_at' => null, ]); if (!empty($validated['reason'])) { JobDescriptionComment::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'comment' => '[Sent back for revision] ' . $validated['reason'], ]); } $this->log($jobDescription, JobDescriptionActivityLog::EVENT_SENT_BACK, [ 'reason' => $validated['reason'] ?? null, ]); }); return response()->json(['success' => true, 'message' => 'Job description sent back for revision.']); } // Publish approved JD public function publish(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeAdmin(); $this->authorizeJD($jobDescription); if (!$jobDescription->isApproved()) { return response()->json([ 'success' => false, 'message' => 'A job description must be approved before it can be published.', ], 422); } DB::transaction(function () use ($jobDescription) { $jobDescription->update([ 'status' => JobDescription::STATUS_PUBLISHED, 'published_by' => Auth::id(), 'published_at' => now(), 'published_link_token' => Str::uuid()->toString(), ]); $this->log($jobDescription, JobDescriptionActivityLog::EVENT_PUBLISHED); }); return response()->json([ 'success' => true, 'message' => 'Job description published successfully.', 'public_url' => route('jd.public', ['token' => $jobDescription->fresh()->published_link_token]), ]); } // Generates a PDF or DOCX file, or a shareable link. public function export(Request $request, JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); if (!$jobDescription->canBeExported()) { return response()->json([ 'success' => false, 'message' => 'A job description must be published before it can be exported.', ], 422); } $validated = $request->validate([ 'export_type' => ['required', Rule::in([ JobDescriptionExport::TYPE_PDF, JobDescriptionExport::TYPE_DOCX, JobDescriptionExport::TYPE_LINK, ])], 'expires_at' => 'nullable|date|after:now', ]); try { $export = DB::transaction(function () use ($validated, $jobDescription) { $filePath = null; $shareToken = null; $expiresAt = null; if ($validated['export_type'] === JobDescriptionExport::TYPE_LINK) { // Reuse the published token for the shareable link $shareToken = $jobDescription->published_link_token; $expiresAt = $validated['expires_at'] ?? null; // no expiry by default for public link } else { $filePath = $this->generateExportFile($jobDescription, $validated['export_type']); } return JobDescriptionExport::create([ 'job_description_id' => $jobDescription->id, 'export_type' => $validated['export_type'], 'exported_by' => Auth::id(), 'file_path' => $filePath, 'share_token' => $shareToken, 'expires_at' => $expiresAt, ]); }); $responseData = ['success' => true, 'export_type' => $export->export_type]; if ($export->export_type === JobDescriptionExport::TYPE_LINK) { $responseData['share_url'] = route('jd.public', ['token' => $export->share_token]); } else { $responseData['file_url'] = $export->file_url; } return response()->json($responseData); } catch (\Throwable $e) { Log::error('[JDWizardController] export failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'message' => 'Export failed.'], 500); } } // Returns full JD data plus activity log for the preview screen. public function preview(JobDescription $jobDescription): JsonResponse { $this->authorizeJD($jobDescription); $jobDescription->load([ 'sections', 'creator', 'approver', 'publisher', 'organization', 'validationChecks', 'activityLog.user', 'comments.user', ]); $checks = $jobDescription->validationChecks; return response()->json([ 'success' => true, 'job_description' => $jobDescription, 'can_be_edited' => $jobDescription->canBeEdited(), 'can_be_exported' => $jobDescription->canBeExported(), 'validation_summary' => $checks->isNotEmpty() ? [ 'total_checks' => $checks->count(), 'total_aligned' => $checks->where('passed', true)->count(), 'total_needs_review' => $checks->where('passed', false)->count(), ] : null, 'activity_log' => $jobDescription->activityLog->map(fn ($entry) => [ 'id' => $entry->id, 'event' => $entry->event, 'label' => $entry->label(), 'meta' => $entry->meta, 'user' => $entry->user?->name ?? 'System', 'created_at' => $entry->created_at, ])->values(), 'public_url' => $jobDescription->isPublished() ? route('jd.public', ['token' => $jobDescription->published_link_token]) : null, ]); } // Resolves by published_link_token. public function publicView(string $token) { $jobDescription = JobDescription::where('published_link_token', $token) ->where('status', JobDescription::STATUS_PUBLISHED) ->with(['sections' => fn ($q) => $q->orderBy('sort_order'), 'organization']) ->firstOrFail(); // Return a view for Blade rendering return view('jd-wizard.public', compact('jobDescription')); } // Private Helpers /** * Ensure the JD belongs to the authenticated user's organization or is owned by them directly (for personal JDs with no org). */ private function authorizeJD(JobDescription $jobDescription): void { $user = Auth::user(); // Org-scoped JD if ($jobDescription->organization_id && $user->organization_id) { if ((int) $jobDescription->organization_id !== (int) $user->organization_id) { abort(403, 'This job description does not belong to your organization.'); } } // Personal JD or must be creator if ((int) $jobDescription->created_by !== (int) $user->id) { abort(403, 'You do not have permission to access this job description.'); } } /** * Reject any mutation on a published JD. */ private function assertEditable(JobDescription $jobDescription): void { if (!$jobDescription->canBeEdited()) { abort(422, 'Published job descriptions cannot be edited.'); } } /** * Save a version snapshot of the current JD state. */ private function saveVersion(JobDescription $jobDescription): void { JobDescriptionVersion::create([ 'job_description_id' => $jobDescription->id, 'version_number' => $jobDescription->getLatestVersionNumber() + 1, 'snapshot_json' => $jobDescription->fresh()->toSnapshot(), 'created_by' => Auth::id(), ]); } /** * Append an entry to the activity log. */ private function log(JobDescription $jobDescription, string $event, array $meta = []): void { JobDescriptionActivityLog::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'event' => $event, 'meta' => empty($meta) ? null : $meta, ]); } // Export file generation private function generateExportFile(JobDescription $jobDescription, string $type): string { $jobDescription->loadMissing(['sections', 'creator', 'organization']); Storage::disk('public')->makeDirectory('jd_exports'); $slug = Str::slug($jobDescription->job_title); $filename = "jd_exports/{$slug}_{$jobDescription->id}.{$type}"; match ($type) { JobDescriptionExport::TYPE_PDF => $this->exportAsPdf($jobDescription, $filename), JobDescriptionExport::TYPE_DOCX => $this->exportAsDocx($jobDescription, $filename), }; return $filename; } private function exportAsPdf(JobDescription $jobDescription, string $filename): void { $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView( 'jd-wizard.export-pdf', ['jobDescription' => $jobDescription] ); $pdf->setPaper('A4', 'portrait'); Storage::disk('public')->put($filename, $pdf->output()); } private function exportAsDocx(JobDescription $jobDescription, string $filename): void { $phpWord = new \PhpOffice\PhpWord\PhpWord(); $phpWord->setDefaultFontName('Arial'); $phpWord->setDefaultFontSize(11); $phpWord->getDocInfo() ->setTitle($jobDescription->job_title) ->setCreator($jobDescription->creator?->name ?? 'System') ->setCompany($jobDescription->organization?->name ?? ''); $phpWord->addTitleStyle(1, ['bold' => true, 'size' => 20, 'color' => '1F3864'], ['spaceAfter' => 120]); $phpWord->addTitleStyle(2, ['bold' => true, 'size' => 13, 'color' => '2E5496'], ['spaceBefore' => 240, 'spaceAfter' => 80]); $phpWord->addParagraphStyle('body', ['spaceAfter' => 80, 'lineHeight' => 1.3]); $phpWord->addFontStyle('metaLabel', ['bold' => true, 'size' => 10, 'color' => '666666']); $phpWord->addFontStyle('metaValue', ['size' => 10]); $phpWord->addFontStyle('bodyText', ['size' => 11]); $section = $phpWord->addSection([ 'marginTop' => 1440, 'marginBottom' => 1440, 'marginLeft' => 1440, 'marginRight' => 1440, ]); $section->addTitle($jobDescription->job_title, 1); foreach (array_filter([ 'Department' => $jobDescription->department, 'Level' => $jobDescription->level, 'Location' => $jobDescription->location, 'Employment Type' => $jobDescription->employment_type, ]) as $label => $value) { $para = $section->addTextRun(['spaceAfter' => 40]); $para->addText("{$label}: ", 'metaLabel'); $para->addText($value, 'metaValue'); } $section->addTextBreak(1); // Core sections first, then custom sections foreach ($jobDescription->sections->sortBy('sort_order') as $jdSection) { if (empty(trim($jdSection->content))) continue; $section->addTitle($jdSection->label(), 2); foreach (preg_split('/\r\n|\r|\n/', trim($jdSection->content)) as $line) { $line = trim($line); if ($line === '') { $section->addTextBreak(1); continue; } if (preg_match('/^[-•*]\s+(.+)/', $line, $matches)) { $section->addListItem( $matches[1], 0, 'bodyText', ['listType' => \PhpOffice\PhpWord\Style\ListItem::TYPE_BULLET_FILLED] ); } else { $section->addText($line, 'bodyText', 'body'); } } $section->addTextBreak(1); } $footer = $section->addFooter(); $footer->addPreserveText( ($jobDescription->organization?->name ?? '') . ' | Page {PAGE} of {NUMPAGES}', ['size' => 9, 'color' => '999999'], ['alignment' => \PhpOffice\PhpWord\SimpleType\Jc::CENTER] ); $tmpPath = tempnam(sys_get_temp_dir(), 'jd_docx_') . '.docx'; $writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007'); $writer->save($tmpPath); Storage::disk('public')->put($filename, file_get_contents($tmpPath)); @unlink($tmpPath); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.30 | Generation time: 0 |
proxy
|
phpinfo
|
Settings