diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9b573e59..e78ca953 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,25 @@ jobs: - name: Checkout the code uses: actions/checkout@v2 - - name: Check for code style violation with PHP-CS-Fixer - uses: OskarStark/php-cs-fixer-ga@3.0.0 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Get Composer cache directory + id: cache-composer + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Restore Composer cache + uses: actions/cache@v3 with: - args: --diff --dry-run + path: ${{ steps.cache-composer.outputs.dir }} + key: ${{ runner.os }}-${{ github.ref_name }}-composer-${{ hashFiles('**/composer.lock') }} + + - name: Install Composer dependencies + run: | + composer install --no-interaction --prefer-dist + + - name: Check for code style violation with PHP-CS-Fixer + run: vendor/bin/php-cs-fixer fix --diff diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17f078d7..014dc951 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Its best to: If you're forking the repository and wish to keep your copy up-to-date with the master, ensure you run this command: -`git remote add upstream git@github.com:osiset/laravel-shopify.git` +`git remote add upstream git@github.com:Kyon147/laravel-shopify.git` You can then update by simply running: diff --git a/src/Actions/AfterAuthorize.php b/src/Actions/AfterAuthorize.php index 4170c5ab..efe29b6a 100644 --- a/src/Actions/AfterAuthorize.php +++ b/src/Actions/AfterAuthorize.php @@ -58,6 +58,7 @@ public function __invoke(ShopIdValue $shopId): bool } else { // Run later $job::dispatch($shop) + ->onConnection(Util::getShopifyConfig('job_connections')['after_authenticate']) ->onQueue(Util::getShopifyConfig('job_queues')['after_authenticate']); } diff --git a/src/Actions/DispatchScripts.php b/src/Actions/DispatchScripts.php index 9c65e6d3..f51e497d 100644 --- a/src/Actions/DispatchScripts.php +++ b/src/Actions/DispatchScripts.php @@ -69,7 +69,8 @@ public function __invoke(ShopIdValue $shopId, bool $inline = false): bool ($this->jobClass)::dispatch( $shop->getId(), $scripttags - )->onQueue(Util::getShopifyConfig('job_queues')['scripttags']); + )->onConnection(Util::getShopifyConfig('job_connections')['scripttags']) + ->onQueue(Util::getShopifyConfig('job_queues')['scripttags']); } return true; diff --git a/src/Actions/DispatchWebhooks.php b/src/Actions/DispatchWebhooks.php index ea00ca15..76289e4f 100644 --- a/src/Actions/DispatchWebhooks.php +++ b/src/Actions/DispatchWebhooks.php @@ -69,7 +69,8 @@ public function __invoke(ShopIdValue $shopId, bool $inline = false): bool ($this->jobClass)::dispatch( $shop->getId(), $webhooks - )->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); + )->onConnection(Util::getShopifyConfig('job_connections')['webhooks']) + ->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); } return true; diff --git a/src/Http/Middleware/Billable.php b/src/Http/Middleware/Billable.php index 9e3a1afa..2ab3a75f 100644 --- a/src/Http/Middleware/Billable.php +++ b/src/Http/Middleware/Billable.php @@ -41,6 +41,7 @@ public function handle(Request $request, Closure $next) array_merge($request->input(), [ 'shop' => $shop->getDomain()->toNative(), 'host' => $request->get('host'), + 'locale' => $request->get('locale'), ]) ); } diff --git a/src/Http/Middleware/VerifyScopes.php b/src/Http/Middleware/VerifyScopes.php index 26b82206..9991fa40 100644 --- a/src/Http/Middleware/VerifyScopes.php +++ b/src/Http/Middleware/VerifyScopes.php @@ -46,6 +46,7 @@ public function handle(Request $request, Closure $next) [ 'shop' => $shop->getDomain()->toNative(), 'host' => $request->get('host'), + 'locale' => $request->get('locale'), ] ); } diff --git a/src/Http/Middleware/VerifyShopify.php b/src/Http/Middleware/VerifyShopify.php index f0f6e11d..f2f29cd7 100644 --- a/src/Http/Middleware/VerifyShopify.php +++ b/src/Http/Middleware/VerifyShopify.php @@ -305,6 +305,7 @@ protected function tokenRedirect(Request $request): RedirectResponse 'shop' => ShopDomain::fromRequest($request)->toNative(), 'target' => $target, 'host' => $request->get('host'), + 'locale' => $request->get('locale'), ] ); } @@ -320,7 +321,7 @@ protected function installRedirect(ShopDomainValue $shopDomain): RedirectRespons { return Redirect::route( Util::getShopifyConfig('route_names.authenticate'), - ['shop' => $shopDomain->toNative(), 'host' => request('host')] + ['shop' => $shopDomain->toNative(), 'host' => request('host'), 'locale' => request('locale')] ); } diff --git a/src/Macros/TokenUrl.php b/src/Macros/TokenUrl.php index c40adad0..799fbec2 100644 --- a/src/Macros/TokenUrl.php +++ b/src/Macros/TokenUrl.php @@ -29,6 +29,7 @@ public function generateParams(string $route, array $params = [], bool $absolute 'shop' => ShopDomain::fromRequest(Request::instance())->toNative(), 'target' => URL::route($route, $params, $absolute), 'host' => Request::instance()->get('host'), + 'locale' => Request::instance()->get('locale'), ], ]; } diff --git a/src/Objects/Enums/SessionTokenSource.php b/src/Objects/Enums/SessionTokenSource.php new file mode 100644 index 00000000..ce7539cf --- /dev/null +++ b/src/Objects/Enums/SessionTokenSource.php @@ -0,0 +1,31 @@ +parts = explode('.', $this->string); $body = json_decode(Util::base64UrlDecode($this->parts[1]), true); + $this->tokenSource = $this->determineTokenSource($body); + // Confirm token is not malformed Assert::thatAll([ - $body['iss'], $body['dest'], $body['aud'], - $body['sub'], $body['exp'], $body['nbf'], $body['iat'], $body['jti'], - $body['sid'], + ... $this->tokenSource === SessionTokenSource::APP + ? [ + $body['iss'], + $body['sub'], + $body['sid'], + ] + : [], ])->notNull(self::EXCEPTION_MALFORMED); // Format the values - $this->iss = $body['iss']; + $this->iss = $body['iss'] ?? ''; $this->dest = $body['dest']; $this->aud = $body['aud']; - $this->sub = $body['sub']; + $this->sub = $body['sub'] ?? ''; $this->jti = $body['jti']; - $this->sid = SessionId::fromNative($body['sid']); + $this->sid = SessionId::fromNative($body['sid'] ?? ''); $this->exp = new Carbon($body['exp']); $this->nbf = new Carbon($body['nbf']); $this->iat = new Carbon($body['iat']); // Parse the shop domain from the destination - $host = parse_url($body['dest'], PHP_URL_HOST); + $host = $this->findHost($body['dest']); $this->shopDomain = NullableShopDomain::fromNative($host); } @@ -357,7 +372,10 @@ protected function verifySignature(): void */ protected function verifyValidity(): void { - Assert::that($this->iss)->contains($this->dest, self::EXCEPTION_INVALID); + if ($this->tokenSource === SessionTokenSource::APP) { + Assert::that($this->iss)->contains($this->dest, self::EXCEPTION_INVALID); + } + Assert::that($this->aud)->eq(Util::getShopifyConfig('api_key', $this->getShopDomain()), self::EXCEPTION_INVALID); } @@ -377,4 +395,20 @@ protected function verifyExpiration(): void $now->lessThan($this->getLeewayIssuedAt()), ])->false(self::EXCEPTION_EXPIRED); } + + protected function determineTokenSource(array $body): int + { + if (!isset($body['iss']) && !isset($body['sid'])) { + return SessionTokenSource::CHECKOUT_EXTENSION; + } + + return SessionTokenSource::APP; + } + + protected function findHost(string $destination): ?string + { + return Str::startsWith($destination, 'https') + ? parse_url($destination, PHP_URL_HOST) + : $destination; + } } diff --git a/src/Traits/AuthController.php b/src/Traits/AuthController.php index 8f2865f2..ab443566 100644 --- a/src/Traits/AuthController.php +++ b/src/Traits/AuthController.php @@ -68,6 +68,7 @@ public function authenticate(Request $request, AuthenticateShop $authShop) 'authUrl' => $result['url'], 'host' => $request->get('host'), 'shopDomain' => $shopDomain, + 'locale' => $request->get('locale'), ] ); } else { @@ -77,6 +78,7 @@ public function authenticate(Request $request, AuthenticateShop $authShop) [ 'shop' => $shopDomain->toNative(), 'host' => $request->get('host'), + 'locale' => $request->get('locale'), ] ); } @@ -100,11 +102,16 @@ public function token(Request $request) $params = Util::parseQueryString($query); $params['shop'] = $params['shop'] ?? $shopDomain->toNative() ?? ''; $params['host'] = $request->get('host'); + $params['locale'] = $request->get('locale'); unset($params['token']); $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); } else { - $params = ['shop' => $shopDomain->toNative() ?? '', 'host' => $request->get('host')]; + $params = [ + 'shop' => $shopDomain->toNative() ?? '', + 'host' => $request->get('host'), + 'locale' => $request->get('locale'), + ]; $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); } diff --git a/src/Traits/BillingController.php b/src/Traits/BillingController.php index 8636a9fe..93fd8d65 100644 --- a/src/Traits/BillingController.php +++ b/src/Traits/BillingController.php @@ -60,6 +60,7 @@ public function index( [ 'url' => $url, 'host' => $host, + 'locale' => $request->get('locale'), 'apiKey' => Util::getShopifyConfig('api_key', ShopDomain::fromNative($request->get('shop'))), ] ); @@ -89,6 +90,7 @@ public function process( return Redirect::route(Util::getShopifyConfig('route_names.home'), [ 'shop' => $shop->getDomain()->toNative(), 'host' => $host, + 'locale' => $request->get('locale'), ]); } // Activate the plan and save @@ -99,16 +101,20 @@ public function process( $host ); + $data = [ + 'shop' => $shop->getDomain()->toNative(), + 'host' => $host, + 'locale' => $request->get('locale'), + ]; + + if (!Util::useNativeAppBridge()) { + $data['billing'] = $result ? 'success' : 'failure'; + } + // Go to homepage of app return Redirect::route( Util::getShopifyConfig('route_names.home'), - array_merge([ - 'shop' => $shop->getDomain()->toNative(), - 'host' => $host, - ], Util::useNativeAppBridge() ? [] : [ - 'host' => $host, - 'billing' => $result ? 'success' : 'failure', - ]) + $data )->with( $result ? 'success' : 'failure', 'billing' diff --git a/src/Traits/WebhookController.php b/src/Traits/WebhookController.php index 56b45b16..0ec401cd 100644 --- a/src/Traits/WebhookController.php +++ b/src/Traits/WebhookController.php @@ -35,7 +35,8 @@ public function handle(string $type, Request $request): ResponseResponse $jobClass::dispatch( $request->header('x-shopify-shop-domain'), $jobData - )->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); + )->onConnection(Util::getShopifyConfig('job_connections')['webhooks']) + ->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); return Response::make('', ResponseResponse::HTTP_CREATED); } diff --git a/src/resources/config/shopify-app.php b/src/resources/config/shopify-app.php index 6eaae41a..6acbfd7a 100644 --- a/src/resources/config/shopify-app.php +++ b/src/resources/config/shopify-app.php @@ -25,6 +25,19 @@ 'manual_migrations' => (bool) env('SHOPIFY_MANUAL_MIGRATIONS', false), + /* + |-------------------------------------------------------------------------- + | Sub Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Shopify will be accessible from. If the + | setting is null, Shopify will reside under the same domain as the + | application. Otherwise, this value will be used as the subdomain. + | + */ + + 'domain' => env('SHOPIFY_DOMAIN'), + /* |-------------------------------------------------------------------------- | Manual routes @@ -351,7 +364,7 @@ | in this configuration file is unnecessary. | | If you register the listeners manually again here, the listener will be called twice. - | + | | If you plan to store your listeners in a different directory like `App\Shopify\Listeners` | or within multiple directories, then you should register them here. | @@ -468,7 +481,21 @@ 'scripttags' => env('SCRIPTTAGS_JOB_QUEUE', null), 'after_authenticate' => env('AFTER_AUTHENTICATE_JOB_QUEUE', null), ], + /* + |-------------------------------------------------------------------------- + | Job Connections + |-------------------------------------------------------------------------- + | + | This option is for setting a specific job connection for webhooks, scripttags + | and after_authenticate_job. + | + */ + 'job_connections' => [ + 'webhooks' => env('WEBHOOKS_JOB_CONNECTION', null), + 'scripttags' => env('SCRIPTTAGS_JOB_CONNECTION', null), + 'after_authenticate' => env('AFTER_AUTHENTICATE_JOB_CONNECTION', null), + ], /* |-------------------------------------------------------------------------- | Config API Callback diff --git a/src/resources/routes/api.php b/src/resources/routes/api.php index 4893e46a..1c3fa554 100644 --- a/src/resources/routes/api.php +++ b/src/resources/routes/api.php @@ -13,7 +13,10 @@ $manualRoutes = explode(',', $manualRoutes); } -Route::group(['middleware' => ['api']], function () use ($manualRoutes) { +Route::group([ + 'domain' => Util::getShopifyConfig('domain'), + 'middleware' => ['api'] +], function () use ($manualRoutes) { /* |-------------------------------------------------------------------------- | API Routes diff --git a/src/resources/routes/shopify.php b/src/resources/routes/shopify.php index 28ee6118..df763867 100644 --- a/src/resources/routes/shopify.php +++ b/src/resources/routes/shopify.php @@ -23,7 +23,11 @@ $manualRoutes = explode(',', $manualRoutes); } -Route::group(['prefix' => Util::getShopifyConfig('prefix'), 'middleware' => ['web']], function () use ($manualRoutes) { +Route::group([ + 'domain' => Util::getShopifyConfig('domain'), + 'prefix' => Util::getShopifyConfig('prefix'), + 'middleware' => ['web'] +], function () use ($manualRoutes) { /* |-------------------------------------------------------------------------- | Home Route diff --git a/tests/Actions/AfterAuthorizeTest.php b/tests/Actions/AfterAuthorizeTest.php index 08853c63..b8929a52 100644 --- a/tests/Actions/AfterAuthorizeTest.php +++ b/tests/Actions/AfterAuthorizeTest.php @@ -53,6 +53,47 @@ public function testRunDispatch(): void Queue::assertPushed($jobClass); } + public function testRunDispatchCustomConnection(): void + { + // Fake the queue + Queue::fake(); + + // Create the config + $jobClass = AfterAuthorizeJob::class; + $this->app['config']->set('shopify-app.after_authenticate_job', [ + [ + 'job' => $jobClass, + 'inline' => false, + ], + [ + 'job' => $jobClass, + 'inline' => false, + ], + ]); + + // Define the custom job connection + $customConnection = 'custom_connection'; + + // Set up the configuration + $this->app['config']->set('shopify-app.job_connections', [ + 'after_authenticate' => $customConnection, + ]); + + // Create the shop + $shop = factory($this->model)->create(); + + // Run + call_user_func( + $this->action, + $shop->getId() + ); + + // Assert the job was pushed with the correct connection + Queue::assertPushed($jobClass, function ($job) use ($customConnection) { + return $job->connection === $customConnection; + }); + } + public function testRunInline(): void { // Create the config diff --git a/tests/Actions/DispatchScriptsTest.php b/tests/Actions/DispatchScriptsTest.php index 46bd6472..a6e072dc 100644 --- a/tests/Actions/DispatchScriptsTest.php +++ b/tests/Actions/DispatchScriptsTest.php @@ -72,6 +72,48 @@ public function testRunDispatch(): void $this->assertTrue($result); } + public function testRunDispatchCustomConnection(): void + { + // Fake the queue + Queue::fake(); + + // Create the config + $this->app['config']->set('shopify-app.scripttags', [ + [ + 'src' => 'https://js-aplenty.com/foo.js', + ], + ]); + + // Define the custom job connection + $customConnection = 'custom_connection'; + + // Set up the configuration + $this->app['config']->set('shopify-app.job_connections', [ + 'scripttags' => $customConnection, + ]); + + // Setup API stub + $this->setApiStub(); + ApiStub::stubResponses(['get_script_tags']); + + // Create the shop + $shop = factory($this->model)->create(); + + // Run + $result = call_user_func( + $this->action, + $shop->getId(), + false // async + ); + + // Assert the job was pushed with the correct connection + Queue::assertPushed(ScripttagInstaller::class, function ($job) use ($customConnection) { + return $job->connection === $customConnection; + }); + + $this->assertTrue($result); + } + public function testRunDispatchNow(): void { // Fake the queue diff --git a/tests/Actions/DispatchWebhooksTest.php b/tests/Actions/DispatchWebhooksTest.php index 511d5015..abc4b418 100644 --- a/tests/Actions/DispatchWebhooksTest.php +++ b/tests/Actions/DispatchWebhooksTest.php @@ -77,6 +77,53 @@ public function testRunDispatch(): void $this->assertTrue($result); } + public function testRunDispatchCustomConnection(): void + { + // Fake the queue + Queue::fake(); + + // Create the config + $this->app['config']->set('shopify-app.webhooks', [ + [ + 'topic' => 'orders/create', + 'address' => 'https://localhost/webhooks/orders-create', + ], + [ + 'topic' => 'app/uninstalled', + 'address' => 'http://apple.com/uninstall', + ], + ]); + + // Define the custom job connection + $customConnection = 'custom_connection'; + + // Set up the configuration + $this->app['config']->set('shopify-app.job_connections', [ + 'webhooks' => $customConnection, + ]); + + // Setup API stub + $this->setApiStub(); + ApiStub::stubResponses(['get_webhooks']); + + // Create the shop + $shop = factory($this->model)->create(); + + // Run + $result = call_user_func( + $this->action, + $shop->getId(), + false // async + ); + + // Assert the job was pushed with the correct connection + Queue::assertPushed(WebhookInstaller::class, function ($job) use ($customConnection) { + return $job->connection === $customConnection; + }); + + $this->assertTrue($result); + } + public function testRunDispatchNow(): void { // Fake the queue diff --git a/tests/Objects/Values/SessionTokenTest.php b/tests/Objects/Values/SessionTokenTest.php index c3bbbda9..ff3162fb 100644 --- a/tests/Objects/Values/SessionTokenTest.php +++ b/tests/Objects/Values/SessionTokenTest.php @@ -9,9 +9,48 @@ use Osiset\ShopifyApp\Contracts\Objects\Values\ShopDomain as ShopDomainValue; use Osiset\ShopifyApp\Objects\Values\SessionToken; use Osiset\ShopifyApp\Test\TestCase; +use Osiset\ShopifyApp\Util; class SessionTokenTest extends TestCase { + public function testShouldProcessForValidCheckoutExtensionToken(): void + { + $now = Carbon::now()->unix(); + $this->tokenDefaults = [ + 'dest' => 'shop-name.myshopify.com', + 'aud' => Util::getShopifyConfig('api_key'), + 'exp' => $now + 60, + 'nbf' => $now, + 'iat' => $now, + 'jti' => '00000000-0000-0000-0000-000000000000', + ]; + + $token = $this->buildToken(); + $st = SessionToken::fromNative($token); + + $this->assertInstanceOf(ShopDomainValue::class, $st->getShopDomain()); + $this->assertTrue(Str::contains($this->tokenDefaults['dest'], $st->getShopDomain()->toNative())); + + $this->assertInstanceOf(Carbon::class, $st->getExpiration()); + $this->assertSame($this->tokenDefaults['exp'], $st->getExpiration()->unix()); + + $this->assertInstanceOf(Carbon::class, $st->getIssuedAt()); + $this->assertSame($this->tokenDefaults['iat'], $st->getIssuedAt()->unix()); + + $this->assertInstanceOf(Carbon::class, $st->getNotBefore()); + $this->assertSame($this->tokenDefaults['nbf'], $st->getNotBefore()->unix()); + + $this->assertSame($this->tokenDefaults['dest'], $st->getDestination()); + $this->assertSame($this->tokenDefaults['aud'], $st->getAudience()); + $this->assertSame($this->tokenDefaults['jti'], $st->getTokenId()); + + $this->assertInstanceOf(SessionIdValue::class, $st->getSessionId()); + $this->assertSame('', $st->getSessionId()->toNative()); + + $this->assertSame('', $st->getIssuer()); + $this->assertSame('', $st->getSubject()); + } + public function testShouldProcessForValidToken(): void { $token = $this->buildToken(); diff --git a/tests/Traits/WebhookControllerTest.php b/tests/Traits/WebhookControllerTest.php index 84d0e857..bf9a151a 100644 --- a/tests/Traits/WebhookControllerTest.php +++ b/tests/Traits/WebhookControllerTest.php @@ -98,6 +98,51 @@ public function testHandleWithCustomClassMapping(): void ); } + public function testHandleDispatchesJobWithCustomConnection(): void + { + // Fake the queue + Queue::fake(); + + // Extend Job::class into a custom class + $shop = factory($this->model)->create(['name' => 'example.myshopify.com']); + + // Define the custom job connection + $customConnection = 'custom_connection'; + + // Set up the configuration + $this->app['config']->set('shopify-app.job_connections', [ + 'webhooks' => $customConnection, + ]); + + // Mock headers that match Shopify + $headers = [ + 'HTTP_CONTENT_TYPE' => 'application/json', + 'HTTP_X_SHOPIFY_SHOP_DOMAIN' => $shop->name, + 'HTTP_X_SHOPIFY_HMAC_SHA256' => 'hvTE9wpDzMcDnPEuHWvYZ58ElKn5vHs0LomurfNIuUc=', // Matches fixture data and API secret + ]; + + // Create a webhook call and pass in our own headers and data + $response = $this->call( + 'post', + '/webhook/orders-create-example', + [], + [], + [], + $headers, + file_get_contents(__DIR__.'/../fixtures/webhook.json') + ); + + // Check it was created and job was pushed + $response->assertStatus(Response::HTTP_CREATED); + $response->assertStatus(201); + + + // Assert the job was pushed with the correct connection + Queue::assertPushed(OrdersCreateJob::class, function ($job) use ($customConnection) { + return $job->connection === $customConnection; + }); + } + /** * Override the default config * Allow config change to persist when using $this->call()