feat(export): add shareable links (#41)
* feat(export): add shareable links * chore: reconfigure PW * test: fix namespace * style: apply prettier * chore: reduce workers count in CI for PW tests are running slower (to the point some time out) and flaky * fix: initialize pending request from store immediately * chore: apply rector
This commit is contained in:
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
|
||||
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Actions;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
|
||||
|
||||
@@ -22,59 +23,123 @@ class NimbusIndexController
|
||||
Actions\BuildCurrentUserAction $buildCurrentUserAction,
|
||||
Actions\DisableThirdPartyUiAction $disableThirdPartyUiAction,
|
||||
ActiveApplicationResolver $activeApplicationResolver,
|
||||
ShareableLinkProcessorService $shareableLinkProcessorService,
|
||||
): Renderable|RedirectResponse {
|
||||
$incomingShareableLinkPayload = $this->processShareableLink($shareableLinkProcessorService, $activeApplicationResolver);
|
||||
|
||||
$this->handleApplicationSwitch();
|
||||
|
||||
$this->handleIgnoreRouteError($ignoreRouteErrorAction);
|
||||
|
||||
$this->configureVite();
|
||||
|
||||
$disableThirdPartyUiAction->execute();
|
||||
|
||||
if (request()->has('application')) {
|
||||
return redirect()
|
||||
->to(request()->fullUrlWithQuery(['application' => null]))
|
||||
->withCookie(cookie()->forever(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME, request()->get('application')));
|
||||
}
|
||||
|
||||
Vite::useBuildDirectory('/vendor/nimbus');
|
||||
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
|
||||
|
||||
if (request()->has('ignore')) {
|
||||
$ignoreRouteErrorAction->execute(
|
||||
ignoreData: request()->get('ignore'),
|
||||
);
|
||||
|
||||
return redirect()->to(request()->url());
|
||||
}
|
||||
$baseViewData = [
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
'sharedState' => $incomingShareableLinkPayload,
|
||||
];
|
||||
|
||||
try {
|
||||
$routes = $extractRoutesAction
|
||||
->execute(
|
||||
routes: RouteFacade::getRoutes()->getRoutes(),
|
||||
);
|
||||
} catch (RouteExtractionException $routeExtractionException) {
|
||||
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routeExtractorException' => $this->renderExtractorException($routeExtractionException),
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
]);
|
||||
}
|
||||
$routes = $extractRoutesAction->execute(
|
||||
routes: RouteFacade::getRoutes()->getRoutes(),
|
||||
);
|
||||
|
||||
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routes' => $routes->toFrontendArray(),
|
||||
'headers' => $buildGlobalHeadersAction->execute(),
|
||||
'currentUser' => $buildCurrentUserAction->execute(),
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
]);
|
||||
return view(self::VIEW_NAME, array_merge($baseViewData, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routes' => $routes->toFrontendArray(),
|
||||
'headers' => $buildGlobalHeadersAction->execute(),
|
||||
'currentUser' => $buildCurrentUserAction->execute(),
|
||||
]));
|
||||
} catch (RouteExtractionException $routeExtractionException) {
|
||||
return view(self::VIEW_NAME, array_merge($baseViewData, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routeExtractorException' => $this->formatExtractionException($routeExtractionException),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array<int|string>|string|null>|string|null>
|
||||
* Process shareable link if present in request.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function renderExtractorException(RouteExtractionException $routeExtractionException): array
|
||||
private function processShareableLink(
|
||||
ShareableLinkProcessorService $shareableLinkProcessorService,
|
||||
ActiveApplicationResolver $activeApplicationResolver
|
||||
): ?array {
|
||||
$shareParam = request()->get('share');
|
||||
|
||||
if (! is_string($shareParam)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shareableLinkProcessorService->process($shareParam);
|
||||
|
||||
$targetApp = $shareableLinkProcessorService->getTargetApplication();
|
||||
|
||||
if ($targetApp && $targetApp !== $activeApplicationResolver->getActiveApplicationKey()) {
|
||||
$this->redirectWithApplicationCookie($targetApp);
|
||||
}
|
||||
|
||||
return $shareableLinkProcessorService->toFrontendState();
|
||||
}
|
||||
|
||||
private function handleApplicationSwitch(): void
|
||||
{
|
||||
$application = request()->query('application');
|
||||
|
||||
if ($application === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirectWithApplicationCookie($application);
|
||||
}
|
||||
|
||||
private function redirectWithApplicationCookie(string $application): never
|
||||
{
|
||||
abort(
|
||||
redirect()
|
||||
->to(request()->fullUrlWithQuery(['application' => null]))
|
||||
->withCookie(cookie()->forever(
|
||||
ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME,
|
||||
$application
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private function configureVite(): void
|
||||
{
|
||||
Vite::useBuildDirectory('/vendor/nimbus');
|
||||
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
|
||||
}
|
||||
|
||||
private function handleIgnoreRouteError(Actions\IgnoreRouteErrorAction $ignoreRouteErrorAction): void
|
||||
{
|
||||
$ignoreData = request()->get('ignore');
|
||||
|
||||
if ($ignoreData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ignoreRouteErrorAction->execute(ignoreData: $ignoreData);
|
||||
|
||||
abort(redirect()->to(request()->url()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatExtractionException(RouteExtractionException $routeExtractionException): array
|
||||
{
|
||||
$previous = $routeExtractionException->getPrevious();
|
||||
|
||||
return [
|
||||
'exception' => [
|
||||
'message' => $routeExtractionException->getMessage(),
|
||||
'previous' => $routeExtractionException->getPrevious() instanceof \Throwable ? [
|
||||
'message' => $routeExtractionException->getPrevious()->getMessage(),
|
||||
'file' => $routeExtractionException->getPrevious()->getFile(),
|
||||
'line' => $routeExtractionException->getPrevious()->getLine(),
|
||||
'trace' => Str::replace("\n", '<br/>', $routeExtractionException->getPrevious()->getTraceAsString()),
|
||||
'previous' => $previous instanceof \Throwable ? [
|
||||
'message' => $previous->getMessage(),
|
||||
'file' => $previous->getFile(),
|
||||
'line' => $previous->getLine(),
|
||||
'trace' => Str::replace("\n", '<br/>', $previous->getTraceAsString()),
|
||||
] : null,
|
||||
],
|
||||
'routeContext' => $routeExtractionException->getRouteContext(),
|
||||
|
||||
232
src/Modules/Export/Services/ShareableLinkProcessorService.php
Normal file
232
src/Modules/Export/Services/ShareableLinkProcessorService.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Sunchayn\Nimbus\Modules\Export\Services;
|
||||
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Routing\RouteCollection;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
|
||||
/**
|
||||
* Decodes and processes shareable link payloads.
|
||||
*
|
||||
* Decompresses the URL-safe base64 payload and discovers which
|
||||
* application the shared route belongs to.
|
||||
*/
|
||||
class ShareableLinkProcessorService
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
protected ?array $decodedPayload = null;
|
||||
|
||||
protected bool $routeExists = true;
|
||||
|
||||
protected ?string $targetApplication = null;
|
||||
|
||||
protected ?string $error = null;
|
||||
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
) {}
|
||||
|
||||
public function process(string $shareParam): self
|
||||
{
|
||||
try {
|
||||
$this->decodedPayload = $this->decodePayload($shareParam);
|
||||
$this->discoverRoute();
|
||||
} catch (\Exception $exception) {
|
||||
$this->error = $exception->getMessage();
|
||||
$this->routeExists = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPayload(): bool
|
||||
{
|
||||
return $this->decodedPayload !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getDecodedPayload(): ?array
|
||||
{
|
||||
return $this->decodedPayload;
|
||||
}
|
||||
|
||||
public function routeExists(): bool
|
||||
{
|
||||
return $this->routeExists;
|
||||
}
|
||||
|
||||
public function getTargetApplication(): ?string
|
||||
{
|
||||
return $this->targetApplication;
|
||||
}
|
||||
|
||||
public function getError(): ?string
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payload: array<string, mixed>|null, routeExists: bool, error: string|null}
|
||||
*/
|
||||
public function toFrontendState(): array
|
||||
{
|
||||
return [
|
||||
'payload' => $this->decodedPayload,
|
||||
'routeExists' => $this->routeExists,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected function decodePayload(string $encoded): array
|
||||
{
|
||||
$base64 = $this->restoreBase64FromUrlSafe($encoded);
|
||||
$compressed = $this->decodeBase64($base64);
|
||||
$json = $this->decompress($compressed);
|
||||
|
||||
return $this->parseJson($json);
|
||||
}
|
||||
|
||||
private function restoreBase64FromUrlSafe(string $encoded): string
|
||||
{
|
||||
$base64 = strtr($encoded, '-_', '+/');
|
||||
$padding = strlen($base64) % 4;
|
||||
|
||||
return $padding > 0
|
||||
? $base64.str_repeat('=', 4 - $padding)
|
||||
: $base64;
|
||||
}
|
||||
|
||||
private function decodeBase64(string $base64): string
|
||||
{
|
||||
$decoded = base64_decode($base64, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw new \RuntimeException('Failed to decode base64 payload');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function decompress(string $compressed): string
|
||||
{
|
||||
$json = @gzuncompress($compressed);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to decompress payload');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseJson(string $json): array
|
||||
{
|
||||
/** @var array<string, mixed>|null $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Failed to parse JSON payload: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
return $decoded ?? [];
|
||||
}
|
||||
|
||||
protected function discoverRoute(): void
|
||||
{
|
||||
$method = $this->decodedPayload['method'] ?? 'GET';
|
||||
$endpoint = $this->decodedPayload['endpoint'] ?? '/';
|
||||
|
||||
$this->searchRouteInOtherApplications($method, $endpoint);
|
||||
|
||||
if ($this->targetApplication !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->routeExistsInCurrentApplication($method, $endpoint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->routeExists = false;
|
||||
}
|
||||
|
||||
private function routeExistsInCurrentApplication(string $method, string $endpoint): bool
|
||||
{
|
||||
return $this->findMatchingRoute($method, $endpoint) instanceof \Illuminate\Routing\Route;
|
||||
}
|
||||
|
||||
private function searchRouteInOtherApplications(string $method, string $endpoint): void
|
||||
{
|
||||
/** @var array<string, array{routes?: array{prefix?: string}}> $applications */
|
||||
$applications = $this->config->get('nimbus.applications', []);
|
||||
|
||||
foreach ($applications as $applicationKey => $appConfig) {
|
||||
$prefix = $appConfig['routes']['prefix'] ?? 'api';
|
||||
|
||||
if ($this->findMatchingRoute($method, $endpoint, $prefix) instanceof \Illuminate\Routing\Route) {
|
||||
$this->targetApplication = $applicationKey;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingRoute(string $method, string $endpoint, ?string $prefix = null): ?Route
|
||||
{
|
||||
/** @var RouteCollection $routeCollection */
|
||||
$routeCollection = RouteFacade::getRoutes();
|
||||
|
||||
foreach ($routeCollection as $route) {
|
||||
if ($this->routeMatches($route, $method, $endpoint, $prefix)) {
|
||||
return $route;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function routeMatches(Route $route, string $method, string $endpoint, ?string $prefix = null): bool
|
||||
{
|
||||
if (! $this->methodMatches($route, $method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedEndpoint = $this->normalizePath($endpoint);
|
||||
|
||||
if ($prefix !== null && ! str_starts_with($normalizedEndpoint, $this->normalizePath($prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->pathMatchesRoutePattern($normalizedEndpoint, $route->uri());
|
||||
}
|
||||
|
||||
private function methodMatches(Route $route, string $method): bool
|
||||
{
|
||||
$routeMethods = array_map('strtoupper', $route->methods());
|
||||
|
||||
return in_array(strtoupper($method), $routeMethods, true);
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function pathMatchesRoutePattern(string $path, string $routeUri): bool
|
||||
{
|
||||
$normalizedRouteUri = $this->normalizePath($routeUri);
|
||||
$pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $normalizedRouteUri);
|
||||
|
||||
return (bool) preg_match('#^'.$pattern.'$#', $path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user