diff --git a/composer.json b/composer.json index f5feb1d3..6eab3df6 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ } ], "require": { - "php": ">=7.2", + "php": "^8.2", "ext-json": "*", "funeralzone/valueobjects": "^0.5", "jenssegers/agent": "^2.6", - "laravel/framework": "^7.0 || ^8.0 || ^9.0", + "laravel/framework": "^10.0 || ^11.0", "osiset/basic-shopify-api": "^9.0 || <=10.0.5" }, "require-dev": { diff --git a/src/Actions/ActivatePlan.php b/src/Actions/ActivatePlan.php index a91e9e02..ec000810 100644 --- a/src/Actions/ActivatePlan.php +++ b/src/Actions/ActivatePlan.php @@ -8,6 +8,7 @@ use Osiset\ShopifyApp\Contracts\Objects\Values\PlanId; use Osiset\ShopifyApp\Contracts\Queries\Plan as IPlanQuery; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; +use Osiset\ShopifyApp\Messaging\Events\PlanActivatedEvent; use Osiset\ShopifyApp\Objects\Enums\ChargeStatus; use Osiset\ShopifyApp\Objects\Enums\ChargeType; use Osiset\ShopifyApp\Objects\Enums\PlanType; @@ -142,6 +143,8 @@ public function __invoke(ShopId $shopId, PlanId $planId, ChargeReference $charge $charge = $this->chargeCommand->make($transfer); $this->shopCommand->setToPlan($shopId, $planId); + event(new PlanActivatedEvent($shop, $plan, $charge)); + return $charge; } } diff --git a/src/Actions/AfterAuthorize.php b/src/Actions/AfterAuthorize.php index b682a091..4170c5ab 100644 --- a/src/Actions/AfterAuthorize.php +++ b/src/Actions/AfterAuthorize.php @@ -54,7 +54,7 @@ public function __invoke(ShopIdValue $shopId): bool $job = Arr::get($config, 'job'); if (Arr::get($config, 'inline', false)) { // Run this job immediately - $job::dispatchNow($shop); + $job::dispatchSync($shop); } else { // Run later $job::dispatch($shop) diff --git a/src/Actions/AuthenticateShop.php b/src/Actions/AuthenticateShop.php index 248bcfde..5178348a 100644 --- a/src/Actions/AuthenticateShop.php +++ b/src/Actions/AuthenticateShop.php @@ -4,7 +4,9 @@ use Illuminate\Http\Request; use Osiset\ShopifyApp\Contracts\ApiHelper as IApiHelper; +use Osiset\ShopifyApp\Messaging\Events\AppInstalledEvent; use Osiset\ShopifyApp\Objects\Values\ShopDomain; +use Osiset\ShopifyApp\Util; /** * Authenticates a shop and fires post authentication actions. @@ -105,6 +107,9 @@ public function __invoke(Request $request): array call_user_func($this->dispatchWebhooksAction, $result['shop_id'], false); call_user_func($this->afterAuthorizeAction, $result['shop_id']); + event(new AppInstalledEvent($result['shop_id'])); + + return [$result, true]; } } diff --git a/src/Actions/DispatchScripts.php b/src/Actions/DispatchScripts.php index 7afa0e0c..43b7ce40 100644 --- a/src/Actions/DispatchScripts.php +++ b/src/Actions/DispatchScripts.php @@ -61,7 +61,7 @@ public function __invoke(ShopIdValue $shopId, bool $inline = false): bool // Run the installer job if ($inline) { - ($this->jobClass)::dispatchNow( + ($this->jobClass)::dispatchSync( $shop->getId(), $scripttags ); diff --git a/src/Actions/DispatchWebhooks.php b/src/Actions/DispatchWebhooks.php index 1bea1130..ea00ca15 100644 --- a/src/Actions/DispatchWebhooks.php +++ b/src/Actions/DispatchWebhooks.php @@ -61,7 +61,7 @@ public function __invoke(ShopIdValue $shopId, bool $inline = false): bool // Run the installer job if ($inline) { - ($this->jobClass)::dispatchNow( + ($this->jobClass)::dispatchSync( $shop->getId(), $webhooks ); diff --git a/src/Actions/InstallShop.php b/src/Actions/InstallShop.php index ea89ad77..948f3d7f 100644 --- a/src/Actions/InstallShop.php +++ b/src/Actions/InstallShop.php @@ -55,6 +55,14 @@ public function __construct( */ public function __invoke(ShopDomain $shopDomain, ?string $code): array { + + if (!$this->isValidShop($shopDomain)) { + return [ + 'completed' => false, + 'url' => null, + 'shop_id' => null, + ]; + } // Get the shop $shop = $this->shopQuery->getByDomain($shopDomain, [], true); if ($shop === null) { @@ -102,4 +110,13 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): array ]; } } + + public function isValidShop(ShopDomain $shopDomain): bool + { + $regex = '/^[a-zA-Z0-9][a-zA-Z0-9\-]*.myshopify.com/'; + $isMatched = preg_match($regex, $shopDomain->toNative(), $matches, PREG_OFFSET_CAPTURE); + + return $isMatched === 1; + } + } diff --git a/src/Http/Middleware/IframeProtection.php b/src/Http/Middleware/IframeProtection.php index fb297530..d14078b6 100644 --- a/src/Http/Middleware/IframeProtection.php +++ b/src/Http/Middleware/IframeProtection.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Cache; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; use Osiset\ShopifyApp\Objects\Values\ShopDomain; +use Osiset\ShopifyApp\Util; /** * Responsibility for protection against clickjaking @@ -44,6 +45,7 @@ public function __construct( public function handle(Request $request, Closure $next) { $response = $next($request); + $ancestors = Util::getShopifyConfig('iframe_ancestors'); $shop = Cache::remember( 'frame-ancestors_'.$request->get('shop'), @@ -57,9 +59,15 @@ function () use ($request) { ? $shop->name : '*.myshopify.com'; + $iframeAncestors = "frame-ancestors https://admin.shopify.com https://$domain"; + + if (!blank($ancestors)) { + $iframeAncestors .= ' ' . $ancestors; + } + $response->headers->set( 'Content-Security-Policy', - "frame-ancestors https://$domain https://admin.shopify.com" + $iframeAncestors ); return $response; diff --git a/src/Http/Middleware/VerifyShopify.php b/src/Http/Middleware/VerifyShopify.php index c312ffd5..ac00935c 100644 --- a/src/Http/Middleware/VerifyShopify.php +++ b/src/Http/Middleware/VerifyShopify.php @@ -102,9 +102,11 @@ public function handle(Request $request, Closure $next) } if (!Util::useNativeAppBridge()) { - $storeResult = !$this->isApiRequest($request) && $this->checkPreviousInstallation($request); + $shop = $this->getShopIfAlreadyInstalled($request); + $storeResult = !$this->isApiRequest($request) && $shop; if ($storeResult) { + $this->loginFromShop($shop); return $next($request); } } @@ -511,4 +513,35 @@ protected function checkPreviousInstallation(Request $request): bool return $shop && $shop->password && ! $shop->trashed(); } + + /** + * Get shop model if there is a store record in the database. + * + * @param Request $request The request object. + * + * @return ?ShopModel + */ + protected function getShopIfAlreadyInstalled(Request $request): ?ShopModel + { + $shop = $this->shopQuery->getByDomain(ShopDomain::fromRequest($request), [], true); + + return $shop && $shop->password && ! $shop->trashed() ? $shop : null; + } + + /** + * Login and validate store + * + * @param ShopModel $shop + * @return void + */ + protected function loginFromShop(ShopModel $shop): void + { + // Override auth guard + if (($guard = Util::getShopifyConfig('shop_auth_guard'))) { + $this->auth->setDefaultDriver($guard); + } + + // All is well, login the shop + $this->auth->login($shop); + } } diff --git a/src/Messaging/Events/AppInstalledEvent.php b/src/Messaging/Events/AppInstalledEvent.php new file mode 100644 index 00000000..cef4e658 --- /dev/null +++ b/src/Messaging/Events/AppInstalledEvent.php @@ -0,0 +1,35 @@ +shopId = $shop_id; + } +} diff --git a/src/Messaging/Events/AppUninstalledEvent.php b/src/Messaging/Events/AppUninstalledEvent.php new file mode 100644 index 00000000..cb02e57b --- /dev/null +++ b/src/Messaging/Events/AppUninstalledEvent.php @@ -0,0 +1,35 @@ +shop = $shop; + } +} diff --git a/src/Messaging/Events/PlanActivatedEvent.php b/src/Messaging/Events/PlanActivatedEvent.php new file mode 100644 index 00000000..8e3b8180 --- /dev/null +++ b/src/Messaging/Events/PlanActivatedEvent.php @@ -0,0 +1,53 @@ +shop = $shop; + $this->plan = $plan; + $this->chargeId = $chargeId; + } +} diff --git a/src/Messaging/Events/ShopAuthenticatedEvent.php b/src/Messaging/Events/ShopAuthenticatedEvent.php new file mode 100644 index 00000000..1a1c7b3e --- /dev/null +++ b/src/Messaging/Events/ShopAuthenticatedEvent.php @@ -0,0 +1,35 @@ +shopId = $shop_id; + } +} diff --git a/src/Messaging/Events/ShopDeletedEvent.php b/src/Messaging/Events/ShopDeletedEvent.php new file mode 100644 index 00000000..f901dd1f --- /dev/null +++ b/src/Messaging/Events/ShopDeletedEvent.php @@ -0,0 +1,35 @@ +shop = $shop; + } +} diff --git a/src/Messaging/Jobs/AppUninstalledJob.php b/src/Messaging/Jobs/AppUninstalledJob.php index c973e69c..047758a6 100644 --- a/src/Messaging/Jobs/AppUninstalledJob.php +++ b/src/Messaging/Jobs/AppUninstalledJob.php @@ -10,6 +10,7 @@ use Osiset\ShopifyApp\Actions\CancelCurrentPlan; use Osiset\ShopifyApp\Contracts\Commands\Shop as IShopCommand; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; +use Osiset\ShopifyApp\Messaging\Events\AppUninstalledEvent; use Osiset\ShopifyApp\Objects\Values\ShopDomain; use Osiset\ShopifyApp\Util; use stdClass; @@ -89,6 +90,8 @@ public function handle( // Soft delete the shop. $shopCommand->softDelete($shopId); + event(new AppUninstalledEvent($shop)); + return true; } } diff --git a/src/ShopifyAppProvider.php b/src/ShopifyAppProvider.php index fdce960d..0775ff77 100644 --- a/src/ShopifyAppProvider.php +++ b/src/ShopifyAppProvider.php @@ -5,6 +5,7 @@ use Illuminate\Routing\Redirector; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Osiset\ShopifyApp\Actions\ActivatePlan as ActivatePlanAction; use Osiset\ShopifyApp\Actions\ActivateUsageCharge as ActivateUsageChargeAction; @@ -67,6 +68,10 @@ public function boot() $this->bootMiddlewares(); $this->bootMacros(); $this->bootDirectives(); + + if (version_compare($this->app->version(), '8.0.0', '<')) { + $this->registerEvents(); + } } /** @@ -83,6 +88,13 @@ public function register() WebhookJobMakeCommand::class, ]); + if (version_compare($this->app->version(), '8.0.0', '>=')) { + $this->booting(function () { + $this->registerEvents(); + }); + } + + // Services (start) $this->app->bind(IApiHelper::class, function () { return new ApiHelper(); @@ -332,4 +344,15 @@ private function bootDirectives(): void { Blade::directive('sessionToken', new SessionToken()); } + + private function registerEvents(): void + { + $events = Util::getShopifyConfig('listen'); + + foreach ($events as $event => $listeners) { + foreach (array_unique($listeners, SORT_REGULAR) as $listener) { + Event::listen($event, $listener); + } + } + } } diff --git a/src/Traits/AuthController.php b/src/Traits/AuthController.php index df537b34..f1af6bd7 100644 --- a/src/Traits/AuthController.php +++ b/src/Traits/AuthController.php @@ -11,6 +11,7 @@ use Osiset\ShopifyApp\Exceptions\MissingAuthUrlException; use Osiset\ShopifyApp\Exceptions\MissingShopDomainException; use Osiset\ShopifyApp\Exceptions\SignatureVerificationException; +use Osiset\ShopifyApp\Messaging\Events\ShopAuthenticatedEvent; use Osiset\ShopifyApp\Objects\Values\ShopDomain; use Osiset\ShopifyApp\Util; @@ -57,6 +58,8 @@ public function authenticate(Request $request, AuthenticateShop $authShop) $shopDomain = $shopDomain->toNative(); $shopOrigin = $shopDomain ?? $request->user()->name; + event(new ShopAuthenticatedEvent($result['shop_id'])); + return View::make( 'shopify-app::auth.fullpage_redirect', [ diff --git a/src/Traits/ShopModel.php b/src/Traits/ShopModel.php index 6c9d26c6..a2972369 100644 --- a/src/Traits/ShopModel.php +++ b/src/Traits/ShopModel.php @@ -11,6 +11,7 @@ use Osiset\ShopifyApp\Contracts\Objects\Values\AccessToken as AccessTokenValue; use Osiset\ShopifyApp\Contracts\Objects\Values\ShopDomain as ShopDomainValue; use Osiset\ShopifyApp\Contracts\Objects\Values\ShopId as ShopIdValue; +use Osiset\ShopifyApp\Messaging\Events\ShopDeletedEvent; use Osiset\ShopifyApp\Objects\Values\AccessToken; use Osiset\ShopifyApp\Objects\Values\SessionContext; use Osiset\ShopifyApp\Objects\Values\ShopDomain; @@ -51,6 +52,10 @@ trait ShopModel protected static function bootShopModel(): void { static::addGlobalScope(new Namespacing()); + + static::deleted(function ($shop) { + event(new ShopDeletedEvent($shop)); + }); } /** diff --git a/src/Util.php b/src/Util.php index 67a5a3e1..b2f03765 100644 --- a/src/Util.php +++ b/src/Util.php @@ -17,7 +17,7 @@ class Util /** * HMAC creation helper. * - * @param array $opts The options for building the HMAC. + * @param array $opts The options for building the HMAC. * @param string $secret The app secret key. * * @return Hmac @@ -36,7 +36,7 @@ public static function createHmac(array $opts, string $secret): Hmac ksort($data); $queryCompiled = []; foreach ($data as $key => $value) { - $queryCompiled[] = "{$key}=".(is_array($value) ? implode(',', $value) : $value); + $queryCompiled[] = "{$key}=" . (is_array($value) ? implode(',', $value) : $value); } $data = implode( $buildQueryWithJoin ? '&' : '', @@ -61,7 +61,7 @@ public static function createHmac(array $opts, string $secret): Hmac * See: https://github.com/rack/rack/blob/f9ad97fd69a6b3616d0a99e6bedcfb9de2f81f6c/lib/rack/query_parser.rb#L36 * * @param string $queryString The query string. - * @param string|null $delimiter The delimiter. + * @param string|null $delimiter The delimiter. * * @return mixed */ @@ -72,12 +72,12 @@ public static function parseQueryString(string $queryString, string $delimiter = $params = []; $split = preg_split( - $delimiter ? $commonSeparator[$delimiter] || '/['.$delimiter.']\s*/' : $defaultSeparator, + $delimiter ? $commonSeparator[$delimiter] || '/[' . $delimiter . ']\s*/' : $defaultSeparator, $queryString ?? '' ); foreach ($split as $part) { - if (! $part) { + if (!$part) { continue; } @@ -135,7 +135,7 @@ public static function base64UrlDecode($data) /** * Checks if the route should be registered or not. * - * @param string $routeToCheck The route name to check. + * @param string $routeToCheck The route name to check. * @param bool|array $routesToExclude The routes which are to be excluded. * * @return bool @@ -158,8 +158,8 @@ public static function registerPackageRoute(string $routeToCheck, $routesToExclu * Used as a helper function so it is accessible in Blade. * The second param of `shop` is important for `config_api_callback`. * - * @param string $key The key to lookup. - * @param mixed $shop The shop domain (string, ShopDomain, etc). + * @param string $key The key to lookup. + * @param mixed $shop The shop domain (string, ShopDomain, etc). * * @return mixed */ @@ -207,8 +207,8 @@ public static function getShopifyConfig(string $key, $shop = null) public static function getGraphQLWebhookTopic(string $topic): string { return Str::of($topic) - ->upper() - ->replaceMatches('/[^A-Z_]/', '_'); + ->upper() + ->replaceMatches('/[^A-Z_]/', '_'); } @@ -229,7 +229,7 @@ public static function getShopsTable(): string */ public static function getShopsTableForeignKey(): string { - return Str::singular(self::getShopsTable()).'_id'; + return Str::singular(self::getShopsTable()) . '_id'; } /** @@ -246,4 +246,11 @@ public static function useNativeAppBridge(): bool return !$frontendEngine->isSame($reactEngine); } + + public static function hasAppLegacySupport(string $feature): bool + { + $legacySupports = self::getShopifyConfig('app_legacy_supports') ?? []; + + return (bool)Arr::get($legacySupports, $feature, true); + } } diff --git a/src/resources/config/shopify-app.php b/src/resources/config/shopify-app.php index d2dec331..62c06308 100644 --- a/src/resources/config/shopify-app.php +++ b/src/resources/config/shopify-app.php @@ -56,13 +56,13 @@ */ 'route_names' => [ - 'home' => env('SHOPIFY_ROUTE_NAME_HOME', 'home'), - 'authenticate' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE', 'authenticate'), - 'authenticate.token' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE_TOKEN', 'authenticate.token'), - 'billing' => env('SHOPIFY_ROUTE_NAME_BILLING', 'billing'), - 'billing.process' => env('SHOPIFY_ROUTE_NAME_BILLING_PROCESS', 'billing.process'), + 'home' => env('SHOPIFY_ROUTE_NAME_HOME', 'home'), + 'authenticate' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE', 'authenticate'), + 'authenticate.token' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE_TOKEN', 'authenticate.token'), + 'billing' => env('SHOPIFY_ROUTE_NAME_BILLING', 'billing'), + 'billing.process' => env('SHOPIFY_ROUTE_NAME_BILLING_PROCESS', 'billing.process'), 'billing.usage_charge' => env('SHOPIFY_ROUTE_NAME_BILLING_USAGE_CHARGE', 'billing.usage_charge'), - 'webhook' => env('SHOPIFY_ROUTE_NAME_WEBHOOK', 'webhook'), + 'webhook' => env('SHOPIFY_ROUTE_NAME_WEBHOOK', 'webhook'), ], /* @@ -326,6 +326,42 @@ 'billing_redirect' => env('SHOPIFY_BILLING_REDIRECT', '/billing/process'), + + /* + |-------------------------------------------------------------------------- + | Enable legacy support for features + |-------------------------------------------------------------------------- + | + */ + 'app_legacy_supports' => [ + 'after_authenticate_job' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Register listeners to the events + |-------------------------------------------------------------------------- + | + */ + + 'listen' => [ + \Osiset\ShopifyApp\Messaging\Events\AppInstalledEvent::class => [ + // \App\Listeners\MyListener::class, + ], + \Osiset\ShopifyApp\Messaging\Events\ShopAuthenticatedEvent::class => [ + // \App\Listeners\MyListener::class, + ], + \Osiset\ShopifyApp\Messaging\Events\ShopDeletedEvent::class => [ + // \App\Listeners\MyListener::class, + ], + \Osiset\ShopifyApp\Messaging\Events\AppUninstalledEvent::class => [ + // \App\Listeners\MyListener::class, + ], + \Osiset\ShopifyApp\Messaging\Events\PlanActivatedEvent::class => [ + // \App\Listeners\MyListener::class, + ], + ], + /* |-------------------------------------------------------------------------- | Shopify Webhooks @@ -382,8 +418,13 @@ | This, like webhooks and scripttag jobs, will fire every time a shop | authenticates, not just once. | + | */ + /* + * @deprecated This will be removed in the next major version. + * @see + */ 'after_authenticate_job' => [ /* [ @@ -404,8 +445,8 @@ */ 'job_queues' => [ - 'webhooks' => env('WEBHOOKS_JOB_QUEUE', null), - 'scripttags' => env('SCRIPTTAGS_JOB_QUEUE', null), + 'webhooks' => env('WEBHOOKS_JOB_QUEUE', null), + 'scripttags' => env('SCRIPTTAGS_JOB_QUEUE', null), 'after_authenticate' => env('AFTER_AUTHENTICATE_JOB_QUEUE', null), ], @@ -490,4 +531,6 @@ | */ 'frontend_engine' => env('SHOPIFY_FRONTEND_ENGINE', 'BLADE'), + + 'iframe_ancestors' => '', ]; diff --git a/tests/Messaging/Jobs/AppUninstalledTest.php b/tests/Messaging/Jobs/AppUninstalledTest.php index e933636d..f4882360 100644 --- a/tests/Messaging/Jobs/AppUninstalledTest.php +++ b/tests/Messaging/Jobs/AppUninstalledTest.php @@ -33,7 +33,7 @@ public function testJobSoftDeletesShopAndCharges(): void $this->assertNotEmpty($shop->password); // Run the job - AppUninstalledJob::dispatchNow( + AppUninstalledJob::dispatchSync( $shop->getDomain()->toNative(), json_decode(file_get_contents(__DIR__.'/../../fixtures/app_uninstalled.json')) );