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:
Mazen Touati
2026-01-24 03:01:32 +01:00
committed by GitHub
parent 106bba7539
commit 2895a0ddc6
40 changed files with 2401 additions and 190 deletions

View File

@@ -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(),

View 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);
}
}