Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m45s
Implemented the first version of review output resolve actions. Included a ReviewOutputResolveActionMapper, commands to seed browser fixtures, updated CustomerReviewWorkspace, EnvironmentReviewResource, UI enforcement, and related views. Also added extensive unit, feature, and browser tests, and updated the design coverage matrix.
543 lines
23 KiB
PHP
543 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EnvironmentReviewSection;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ManagedEnvironmentMembership;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\User;
|
|
use App\Models\UserTenantPreference;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
|
use App\Services\Evidence\EvidenceSnapshotService;
|
|
use App\Support\EnvironmentReviewCompletenessState;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SeedReviewOutputBrowserFixture extends Command
|
|
{
|
|
protected $signature = 'tenantpilot:review-output:seed-browser-fixture';
|
|
|
|
protected $description = 'Seed a local/testing browser fixture for the Spec 351 ready-review publish scenario.';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! app()->environment(['local', 'testing'])) {
|
|
$this->error('This fixture command is limited to local and testing environments.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$fixture = config('tenantpilot.review_output.browser_smoke_fixture');
|
|
|
|
if (! is_array($fixture)) {
|
|
$this->error('The review-output browser smoke fixture is not configured.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
|
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
|
$scenarioConfig = is_array($fixture['ready_draft'] ?? null) ? $fixture['ready_draft'] : [];
|
|
|
|
$workspace = Workspace::query()->updateOrCreate(
|
|
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-351-review-output-smoke')],
|
|
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 351 Review Output Smoke')],
|
|
);
|
|
|
|
$password = (string) ($userConfig['password'] ?? 'password');
|
|
|
|
$user = User::query()->updateOrCreate(
|
|
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+351@tenantpilot.local')],
|
|
[
|
|
'name' => (string) ($userConfig['name'] ?? 'Spec 351 Requester'),
|
|
'password' => Hash::make($password),
|
|
'email_verified_at' => now(),
|
|
],
|
|
);
|
|
|
|
$environment = ManagedEnvironment::query()->updateOrCreate(
|
|
['slug' => (string) ($scenarioConfig['managed_environment_slug'] ?? 'spec-351-browser-ready-draft')],
|
|
[
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'name' => (string) ($scenarioConfig['managed_environment_name'] ?? 'Spec 351 Browser Ready Draft'),
|
|
'lifecycle_status' => ManagedEnvironment::STATUS_ACTIVE,
|
|
'kind' => 'dev',
|
|
'is_current' => false,
|
|
'metadata' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
|
'rbac_status' => 'ok',
|
|
'rbac_last_checked_at' => now(),
|
|
],
|
|
);
|
|
|
|
WorkspaceMembership::query()->updateOrCreate(
|
|
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
|
['role' => 'owner'],
|
|
);
|
|
|
|
ManagedEnvironmentMembership::query()->updateOrCreate(
|
|
['managed_environment_id' => (int) $environment->getKey(), 'user_id' => (int) $user->getKey()],
|
|
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-351-review-output-browser-smoke'],
|
|
);
|
|
|
|
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
}
|
|
|
|
if (Schema::hasTable('user_managed_environment_preferences')) {
|
|
UserTenantPreference::query()->updateOrCreate(
|
|
['user_id' => (int) $user->getKey(), 'managed_environment_id' => (int) $environment->getKey()],
|
|
['last_used_at' => now()],
|
|
);
|
|
}
|
|
|
|
$readyReview = DB::transaction(function () use ($environment, $user, $scenarioConfig): EnvironmentReview {
|
|
$this->resetFixtureGraph($environment);
|
|
|
|
$snapshot = $this->seedReadyEvidenceSnapshot($environment, $user, $scenarioConfig);
|
|
|
|
return $this->seedPublishedLoopWithReadySuccessor($environment, $user, $snapshot, $scenarioConfig);
|
|
});
|
|
|
|
$workspaceUrl = CustomerReviewWorkspace::environmentFilterUrl($environment);
|
|
$detailUrl = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $readyReview], $environment);
|
|
$detailRedirect = $this->relativeAdminRedirect($detailUrl);
|
|
$publishedReviewId = EnvironmentReview::query()
|
|
->where('superseded_by_review_id', (int) $readyReview->getKey())
|
|
->latest('id')
|
|
->value('id');
|
|
$loginUrl = route('admin.local.smoke-login', [
|
|
'email' => (string) $user->email,
|
|
'tenant' => (string) $environment->slug,
|
|
'workspace' => (string) $workspace->slug,
|
|
'redirect' => $detailRedirect,
|
|
], absolute: false);
|
|
|
|
$this->table(
|
|
['Fixture', 'Value'],
|
|
[
|
|
['Workspace', (string) $workspace->name],
|
|
['User email', (string) $user->email],
|
|
['User password', $password],
|
|
['ManagedEnvironment', (string) $environment->name],
|
|
['Workspace URL', $workspaceUrl],
|
|
['Published review id', $publishedReviewId !== null ? (string) $publishedReviewId : '—'],
|
|
['Ready review id', (string) $readyReview->getKey()],
|
|
['Ready review detail URL', $detailUrl],
|
|
['Fixture login URL', $loginUrl],
|
|
],
|
|
);
|
|
|
|
$this->info('The fixture seeds a published review plus a linked ready successor so the workspace shows Open draft review and the detail page shows Publish review.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function resetFixtureGraph(ManagedEnvironment $environment): void
|
|
{
|
|
$environmentId = (int) $environment->getKey();
|
|
$reviewIds = EnvironmentReview::query()
|
|
->where('managed_environment_id', $environmentId)
|
|
->pluck('id');
|
|
$snapshotIds = EvidenceSnapshot::query()
|
|
->where('managed_environment_id', $environmentId)
|
|
->pluck('id');
|
|
|
|
if ($reviewIds->isNotEmpty()) {
|
|
ReviewPack::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
|
EnvironmentReviewSection::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
|
EnvironmentReview::query()->whereIn('id', $reviewIds)->delete();
|
|
}
|
|
|
|
if ($snapshotIds->isNotEmpty()) {
|
|
EvidenceSnapshotItem::query()->whereIn('evidence_snapshot_id', $snapshotIds)->delete();
|
|
EvidenceSnapshot::query()->whereIn('id', $snapshotIds)->delete();
|
|
}
|
|
|
|
StoredReport::query()->where('managed_environment_id', $environmentId)->delete();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function seedReadyEvidenceSnapshot(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
array $scenarioConfig,
|
|
): EvidenceSnapshot {
|
|
StoredReport::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
'payload' => [
|
|
'posture_score' => 92,
|
|
'required_count' => 14,
|
|
'granted_count' => 14,
|
|
'checked_at' => now()->subMinutes(5)->toIso8601String(),
|
|
'permissions' => [
|
|
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
StoredReport::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
'payload' => [
|
|
'provider_key' => 'microsoft',
|
|
'domain' => 'entra.admin_roles',
|
|
'measured_at' => now()->subMinutes(5)->toIso8601String(),
|
|
'roles' => [
|
|
[
|
|
'displayName' => 'Global Administrator',
|
|
'userPrincipalName' => 'admin@contoso.com',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$operationRun = OperationRun::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'initiator_name' => (string) ($scenarioConfig['operation_initiator_name'] ?? 'Spec 351 Browser Operator'),
|
|
'type' => OperationRunType::PolicySync->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'run_identity_hash' => hash('sha256', sprintf(
|
|
'spec-351-review-output-browser-smoke-%s-%s',
|
|
$environment->getKey(),
|
|
(string) Str::uuid(),
|
|
)),
|
|
'summary_counts' => [],
|
|
'failure_summary' => [],
|
|
'context' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
|
'started_at' => now()->subMinutes(6),
|
|
'completed_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
/** @var EvidenceSnapshotService $service */
|
|
$service = app(EvidenceSnapshotService::class);
|
|
$payload = $service->buildSnapshotPayload($environment);
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'fingerprint' => $payload['fingerprint'],
|
|
'completeness_state' => $payload['completeness'],
|
|
'summary' => $payload['summary'],
|
|
'generated_at' => now()->subMinutes(4),
|
|
]);
|
|
|
|
foreach ($payload['items'] as $item) {
|
|
$snapshot->items()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'dimension_key' => $item['dimension_key'],
|
|
'state' => $item['state'],
|
|
'required' => $item['required'],
|
|
'source_kind' => $item['source_kind'],
|
|
'source_record_type' => $item['source_record_type'],
|
|
'source_record_id' => $item['source_record_id'],
|
|
'source_fingerprint' => $item['source_fingerprint'],
|
|
'measured_at' => $item['measured_at'],
|
|
'freshness_at' => $item['freshness_at'],
|
|
'summary_payload' => $item['summary_payload'],
|
|
'sort_order' => $item['sort_order'],
|
|
]);
|
|
}
|
|
|
|
return $snapshot->load('items');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function seedPublishedLoopWithReadySuccessor(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
EvidenceSnapshot $snapshot,
|
|
array $scenarioConfig,
|
|
): EnvironmentReview {
|
|
/** @var EnvironmentReviewService $service */
|
|
$service = app(EnvironmentReviewService::class);
|
|
/** @var EnvironmentReviewLifecycleService $lifecycle */
|
|
$lifecycle = app(EnvironmentReviewLifecycleService::class);
|
|
|
|
Queue::fake();
|
|
|
|
$publishedReview = $service->create($environment, $snapshot, $user)->refresh();
|
|
|
|
if ($publishedReview->generated_at === null || ! $publishedReview->sections()->exists()) {
|
|
$publishedReview = $service->compose($publishedReview);
|
|
}
|
|
|
|
$publishedReview = $this->markReadyReview($publishedReview);
|
|
$publishedReview->forceFill([
|
|
'status' => EnvironmentReviewStatus::Ready->value,
|
|
'published_at' => null,
|
|
'published_by_user_id' => null,
|
|
])->save();
|
|
|
|
$publishedReview = $lifecycle->publish(
|
|
$publishedReview->refresh(),
|
|
$user,
|
|
(string) ($scenarioConfig['publish_reason'] ?? 'Seed published predecessor for Spec 351 browser verification.'),
|
|
);
|
|
|
|
$this->attachPublishedReviewPack($environment, $user, $publishedReview, $snapshot, $scenarioConfig);
|
|
|
|
$successorReview = $lifecycle->createNextReview($publishedReview->fresh(), $user, $snapshot)->refresh();
|
|
|
|
if ($successorReview->generated_at === null || ! $successorReview->sections()->exists()) {
|
|
$successorReview = $service->compose($successorReview);
|
|
}
|
|
|
|
$successorReview = $this->markReadyReview($successorReview);
|
|
$successorReview->forceFill([
|
|
'status' => EnvironmentReviewStatus::Ready->value,
|
|
'published_at' => null,
|
|
'published_by_user_id' => null,
|
|
])->save();
|
|
|
|
return $successorReview->refresh()->load(['tenant', 'evidenceSnapshot.items', 'sections']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function attachPublishedReviewPack(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
EnvironmentReview $review,
|
|
EvidenceSnapshot $snapshot,
|
|
array $scenarioConfig,
|
|
): void {
|
|
$filePath = (string) ($scenarioConfig['published_review_pack_path'] ?? 'review-packs/spec351-browser-ready-published.zip');
|
|
$fileContents = (string) ($scenarioConfig['published_review_pack_contents'] ?? 'PK-spec351-browser-ready-published');
|
|
|
|
Storage::disk('exports')->put($filePath, $fileContents);
|
|
|
|
$fingerprint = hash('sha256', implode('|', [
|
|
'spec-351-review-output-browser-smoke-pack',
|
|
(string) $environment->getKey(),
|
|
(string) $review->getKey(),
|
|
$filePath,
|
|
]));
|
|
|
|
$reviewPack = ReviewPack::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'environment_review_id' => (int) $review->getKey(),
|
|
'status' => ReviewPack::STATUS_READY,
|
|
'fingerprint' => $fingerprint,
|
|
'summary' => [
|
|
'fixture' => 'spec-351-review-output-browser-smoke',
|
|
],
|
|
'options' => [
|
|
'include_pii' => false,
|
|
'include_operations' => true,
|
|
],
|
|
'file_disk' => 'exports',
|
|
'file_path' => $filePath,
|
|
'file_size' => strlen($fileContents),
|
|
'sha256' => hash('sha256', $fileContents),
|
|
'generated_at' => now()->subMinutes(3),
|
|
'expires_at' => now()->addDays(7),
|
|
]);
|
|
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$summary['has_ready_export'] = true;
|
|
|
|
$review->forceFill([
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
'summary' => $summary,
|
|
])->save();
|
|
}
|
|
|
|
private function markReadyReview(EnvironmentReview $review): EnvironmentReview
|
|
{
|
|
$review->loadMissing(['sections', 'evidenceSnapshot.items']);
|
|
|
|
$disclosure = 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.';
|
|
$controlSummary = [
|
|
'control_key' => 'customer-output',
|
|
'control_name' => 'Customer output',
|
|
'domain_key' => 'customer_delivery',
|
|
'readiness_bucket' => 'evidence_on_record',
|
|
'readiness_label' => 'Evidence on record',
|
|
'limitation_flags' => [],
|
|
'limitation_labels' => [],
|
|
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_summary' => '1 evidence signal references this control.',
|
|
'accepted_risk_summary' => null,
|
|
'recommended_next_action' => 'Publish review.',
|
|
'detail_anchor' => 'control-customer-output',
|
|
'supporting_finding_ids' => [],
|
|
'finding_count' => 0,
|
|
'open_finding_count' => 0,
|
|
'accepted_risk_count' => 0,
|
|
];
|
|
$controlExplanation = [
|
|
'title' => 'Customer output',
|
|
'control_key' => 'customer-output',
|
|
'control_name' => 'Customer output',
|
|
'readiness_bucket' => 'evidence_on_record',
|
|
'readiness_label' => 'Evidence on record',
|
|
'limitation_flags' => [],
|
|
'limitation_labels' => [],
|
|
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_summary' => '1 evidence signal references this control.',
|
|
'accepted_risk_summary' => null,
|
|
'explanation_text' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_items' => [
|
|
'1 evidence signal references this control.',
|
|
],
|
|
'accepted_risk_context' => null,
|
|
'recommended_next_action' => 'Publish review.',
|
|
'proof_access_state' => 'available',
|
|
'supporting_finding_ids' => [],
|
|
];
|
|
|
|
$snapshot = $review->evidenceSnapshot;
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
$snapshot->items->each(function (EvidenceSnapshotItem $item): void {
|
|
$item->forceFill([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
])->save();
|
|
});
|
|
|
|
$snapshotSummary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
|
$snapshotSummary['missing_dimensions'] = 0;
|
|
$snapshotSummary['stale_dimensions'] = 0;
|
|
$snapshotSummary['dimensions'] = collect($snapshotSummary['dimensions'] ?? [])
|
|
->map(static function (mixed $dimension): mixed {
|
|
if (! is_array($dimension)) {
|
|
return $dimension;
|
|
}
|
|
|
|
$dimension['state'] = EnvironmentReviewCompletenessState::Complete->value;
|
|
|
|
return $dimension;
|
|
})
|
|
->values()
|
|
->all();
|
|
|
|
$snapshot->forceFill([
|
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
'summary' => $snapshotSummary,
|
|
])->save();
|
|
}
|
|
|
|
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void {
|
|
$attributes = [
|
|
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
|
];
|
|
|
|
if ($section->isControlInterpretation()) {
|
|
$attributes['summary_payload'] = array_replace_recursive([
|
|
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
|
'display_label' => 'Compliance evidence mapping v1',
|
|
'non_certification_disclosure' => $disclosure,
|
|
'mapped_control_count' => 1,
|
|
'follow_up_required_count' => 0,
|
|
'limitation_counts' => [],
|
|
'limitations' => [],
|
|
], is_array($section->summary_payload) ? $section->summary_payload : []);
|
|
$attributes['render_payload'] = array_replace_recursive([
|
|
'entries' => [$controlExplanation],
|
|
'disclosure' => $disclosure,
|
|
'next_actions' => ['Publish review.'],
|
|
'empty_state' => null,
|
|
], is_array($section->render_payload) ? $section->render_payload : []);
|
|
}
|
|
|
|
$section->forceFill($attributes)->save();
|
|
});
|
|
|
|
$sectionCount = $review->sections->count();
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$existingControlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
|
? $summary['control_interpretation']
|
|
: [];
|
|
$existingControls = collect($existingControlInterpretation['controls'] ?? [])
|
|
->filter(static fn (mixed $control): bool => is_array($control))
|
|
->values();
|
|
|
|
$summary['control_interpretation'] = array_replace_recursive([
|
|
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
|
'display_label' => 'Compliance evidence mapping v1',
|
|
'non_certification_disclosure' => $disclosure,
|
|
'mapped_control_count' => 1,
|
|
'follow_up_required_count' => 0,
|
|
'limitation_counts' => [],
|
|
'limitations' => [],
|
|
'controls' => [$controlSummary],
|
|
], $existingControlInterpretation);
|
|
|
|
$summary['control_interpretation']['controls'] = [
|
|
array_replace(
|
|
$controlSummary,
|
|
is_array($existingControls->first()) ? $existingControls->first() : [],
|
|
),
|
|
];
|
|
$summary['publish_blockers'] = [];
|
|
$summary['section_count'] = $sectionCount;
|
|
$summary['section_state_counts'] = [
|
|
'complete' => $sectionCount,
|
|
'partial' => 0,
|
|
'missing' => 0,
|
|
'stale' => 0,
|
|
];
|
|
|
|
$review->forceFill([
|
|
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
|
'summary' => $summary,
|
|
])->save();
|
|
|
|
return $review->refresh();
|
|
}
|
|
|
|
private function relativeAdminRedirect(string $url): string
|
|
{
|
|
$parsed = parse_url($url);
|
|
|
|
$path = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
|
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
|
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
|
|
|
return $path.$query.$fragment;
|
|
}
|
|
}
|