diff --git a/web/app/Http/Controllers/Shopify/AuthController.php b/web/app/Http/Controllers/Shopify/AuthController.php new file mode 100644 index 000000000..4a9c6b103 --- /dev/null +++ b/web/app/Http/Controllers/Shopify/AuthController.php @@ -0,0 +1,64 @@ + + */ +class AuthController extends Controller +{ + public function index(Request $request) + { + $shop = Utils::sanitizeShopDomain($request->query('shop')); + + // Delete any previously created OAuth sessions that were not completed (don't have an access token) + Session::where('shop', $shop)->where('access_token', null)->delete(); + + return AuthRedirection::redirect($request); + } + + public function callback(Request $request) + { + $session = OAuth::callback( + $request->cookie(), + $request->query(), + ['App\Lib\CookieHandler', 'saveShopifyCookie'], + ); + + $host = $request->query('host'); + $shop = Utils::sanitizeShopDomain($request->query('shop')); + + $response = Registry::register('/api/webhooks', Topics::APP_UNINSTALLED, $shop, $session->getAccessToken()); + if ($response->isSuccess()) { + Log::debug("Registered APP_UNINSTALLED webhook for shop $shop"); + } else { + Log::error( + "Failed to register APP_UNINSTALLED webhook for shop $shop with response body: ". + print_r($response->getBody(), true) + ); + } + + $redirectUrl = Utils::getEmbeddedAppUrl($host); + if (Config::get('shopify.billing.required')) { + [$hasPayment, $confirmationUrl] = EnsureBilling::check($session, Config::get('shopify.billing')); + + if (! $hasPayment) { + $redirectUrl = $confirmationUrl; + } + } + + return redirect($redirectUrl); + } +} diff --git a/web/app/Http/Controllers/Shopify/FallbackController.php b/web/app/Http/Controllers/Shopify/FallbackController.php new file mode 100644 index 000000000..b2b6193c0 --- /dev/null +++ b/web/app/Http/Controllers/Shopify/FallbackController.php @@ -0,0 +1,27 @@ + + */ +class FallbackController extends Controller +{ + public function __invoke(Request $request) + { + if (Context::$IS_EMBEDDED_APP && $request->query('embedded', false) === '1') { + if (env('APP_ENV') === 'production') { + return file_get_contents(public_path('index.html')); + } else { + return file_get_contents(base_path('frontend/index.html')); + } + } + + return redirect(Utils::getEmbeddedAppUrl($request->query('host', null)).'/'.$request->path()); + } +} diff --git a/web/app/Http/Controllers/Shopify/ProductController.php b/web/app/Http/Controllers/Shopify/ProductController.php new file mode 100644 index 000000000..77146696c --- /dev/null +++ b/web/app/Http/Controllers/Shopify/ProductController.php @@ -0,0 +1,57 @@ + + */ +class ProductController extends Controller +{ + public function count(Request $request) + { + /** @var \Shopify\Auth\Session */ + $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active + + $client = new Rest($session->getShop(), $session->getAccessToken()); + $result = $client->get('products/count'); + + return response($result->getDecodedBody()); + } + + public function store(Request $request) + { + /** @var \Shopify\Auth\Session */ + $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active + + $success = $code = $error = null; + try { + ProductCreator::call($session, 5); + $success = true; + $code = 200; + $error = null; + } catch (\Exception $e) { + $success = false; + + if ($e instanceof ShopifyProductCreatorException) { + $code = $e->response->getStatusCode(); + $error = $e->response->getDecodedBody(); + if (array_key_exists('errors', $error)) { + $error = $error['errors']; + } + } else { + $code = 500; + $error = $e->getMessage(); + } + + Log::error("Failed to create products: $error"); + } finally { + return response()->json(['success' => $success, 'error' => $error], $code); + } + } +} diff --git a/web/app/Http/Controllers/Shopify/WebhookController.php b/web/app/Http/Controllers/Shopify/WebhookController.php new file mode 100644 index 000000000..95a65ab1d --- /dev/null +++ b/web/app/Http/Controllers/Shopify/WebhookController.php @@ -0,0 +1,38 @@ + + */ +class WebhookController extends Controller +{ + public function __invoke(Request $request) + { + try { + $topic = $request->header(HttpHeaders::X_SHOPIFY_TOPIC, ''); + + $response = Registry::process($request->header(), $request->getContent()); + if (! $response->isSuccess()) { + Log::error("Failed to process '$topic' webhook: {$response->getErrorMessage()}"); + + return response()->json(['message' => "Failed to process '$topic' webhook"], 500); + } + } catch (InvalidWebhookException $e) { + Log::error("Got invalid webhook request for topic '$topic': {$e->getMessage()}"); + + return response()->json(['message' => "Got invalid webhook request for topic '$topic'"], 401); + } catch (\Exception $e) { + Log::error("Got an exception when handling '$topic' webhook: {$e->getMessage()}"); + + return response()->json(['message' => "Got an exception when handling '$topic' webhook"], 500); + } + } +} diff --git a/web/app/Providers/AppServiceProvider.php b/web/app/Providers/AppServiceProvider.php index 5f5288fa4..857898dcc 100644 --- a/web/app/Providers/AppServiceProvider.php +++ b/web/app/Providers/AppServiceProvider.php @@ -2,17 +2,7 @@ namespace App\Providers; -use App\Lib\DbSessionStorage; -use App\Lib\Handlers\AppUninstalled; -use App\Lib\Handlers\Privacy\CustomersDataRequest; -use App\Lib\Handlers\Privacy\CustomersRedact; -use App\Lib\Handlers\Privacy\ShopRedact; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Facades\URL; -use Shopify\Context; -use Shopify\ApiVersion; -use Shopify\Webhooks\Registry; -use Shopify\Webhooks\Topics; class AppServiceProvider extends ServiceProvider { @@ -26,49 +16,8 @@ public function register() // } - /** - * Bootstrap any application services. - * - * @return void - * @throws \Shopify\Exception\MissingArgumentException - */ public function boot() { - $host = str_replace('https://', '', env('HOST', 'not_defined')); - - $customDomain = env('SHOP_CUSTOM_DOMAIN', null); - Context::initialize( - env('SHOPIFY_API_KEY', 'not_defined'), - env('SHOPIFY_API_SECRET', 'not_defined'), - env('SCOPES', 'not_defined'), - $host, - new DbSessionStorage(), - ApiVersion::LATEST, - true, - false, - null, - '', - null, - (array)$customDomain, - ); - - URL::forceRootUrl("https://$host"); - URL::forceScheme('https'); - - Registry::addHandler(Topics::APP_UNINSTALLED, new AppUninstalled()); - - /* - * This sets up the mandatory privacy webhooks. You’ll need to fill in the endpoint to be used by your app in - * the “Privacy webhooks” section in the “App setup” tab, and customize the code when you store customer data - * in the handlers being registered below. - * - * More details can be found on shopify.dev: - * https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks - * - * Note that you'll only receive these webhooks if your app has the relevant scopes as detailed in the docs. - */ - Registry::addHandler('CUSTOMERS_DATA_REQUEST', new CustomersDataRequest()); - Registry::addHandler('CUSTOMERS_REDACT', new CustomersRedact()); - Registry::addHandler('SHOP_REDACT', new ShopRedact()); + // } } diff --git a/web/app/Providers/ShopifyServiceProvider.php b/web/app/Providers/ShopifyServiceProvider.php new file mode 100644 index 000000000..5054d131c --- /dev/null +++ b/web/app/Providers/ShopifyServiceProvider.php @@ -0,0 +1,77 @@ + + */ +class ShopifyServiceProvider extends ServiceProvider +{ + /** + * Register services. + * + * @return void + */ + public function register() + { + // + } + + /** + * Bootstrap shopify service. + * + * @return void + * + * @throws \Shopify\Exception\MissingArgumentException + */ + public function boot() + { + $host = str_replace('https://', '', config('shopify.host')); + + Context::initialize( + config('shopify.api_key'), + config('shopify.api_secret'), + config('shopify.scopes'), + $host, + new DbSessionStorage(), + ApiVersion::LATEST, + true, + false, + null, + '', + null, + (array) config('shopify.shop_custom_domain'), + ); + + URL::forceRootUrl("https://$host"); + URL::forceScheme('https'); + + Registry::addHandler(Topics::APP_UNINSTALLED, new AppUninstalled()); + + /* + * This sets up the mandatory privacy webhooks. You’ll need to fill in the endpoint to be used by your app in + * the “Privacy webhooks” section in the “App setup” tab, and customize the code when you store customer data + * in the handlers being registered below. + * + * More details can be found on shopify.dev: + * https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks + * + * Note that you'll only receive these webhooks if your app has the relevant scopes as detailed in the docs. + */ + Registry::addHandler('CUSTOMERS_DATA_REQUEST', new CustomersDataRequest()); + Registry::addHandler('CUSTOMERS_REDACT', new CustomersRedact()); + Registry::addHandler('SHOP_REDACT', new ShopRedact()); + } +} diff --git a/web/config/app.php b/web/config/app.php index 2dc6c8fb2..6351b0e9f 100644 --- a/web/config/app.php +++ b/web/config/app.php @@ -174,6 +174,7 @@ // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, + App\Providers\ShopifyServiceProvider::class, ], diff --git a/web/config/shopify.php b/web/config/shopify.php index 60631da44..e5e4e4e76 100644 --- a/web/config/shopify.php +++ b/web/config/shopify.php @@ -4,6 +4,65 @@ return [ + /* + |-------------------------------------------------------------------------- + | Shopify host + |-------------------------------------------------------------------------- + | + | The URL origin where the app will be accessed when it's deployed, excluding the protocol. This will be provided by your platform. + | Example: my-deployed-app.fly.dev + | + | Learn more about in documentation: https://shopify.dev/docs/apps/launch/deployment/deploy-web-app/deploy-to-hosting-service#step-4-set-up-environment-variables + | + */ + 'host' => env('HOST'), + + /* + |-------------------------------------------------------------------------- + | Shopify custom domain + |-------------------------------------------------------------------------- + | + | One or more regexps to use when validating domains. + | + */ + 'shop_custom_domain' => env('SHOP_CUSTOM_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Shopify API Key + |-------------------------------------------------------------------------- + | + | The client ID of the app, retrieved using Shopify CLI. + | + | Learn more about in documentation: https://shopify.dev/docs/apps/launch/deployment/deploy-web-app/deploy-to-hosting-service#step-4-set-up-environment-variables + | + */ + 'api_key' => env('SHOPIFY_API_KEY'), + + /* + |-------------------------------------------------------------------------- + | Shopify API Secret + |-------------------------------------------------------------------------- + | + | The client secret of the app, retrieved using Shopify CLI. + | + | Learn more about in documentation: https://shopify.dev/docs/apps/launch/deployment/deploy-web-app/deploy-to-hosting-service#step-4-set-up-environment-variables + | + */ + 'api_secret' => env('SHOPIFY_API_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Shopify Scopes + |-------------------------------------------------------------------------- + | + | The app's access scopes, retrieved using Shopify CLI. This is optional if you're using Shopify-managed installation. + | + | Learn more about in documentation: https://shopify.dev/docs/apps/launch/deployment/deploy-web-app/deploy-to-hosting-service#step-4-set-up-environment-variables + | + */ + 'scopes' => env('SCOPES'), + /* |-------------------------------------------------------------------------- | Shopify billing @@ -17,14 +76,14 @@ | Learn more about billing in our documentation: https://shopify.dev/docs/apps/billing | */ - "billing" => [ - "required" => false, + 'billing' => [ + 'required' => false, // Example set of values to create a charge for $5 one time - "chargeName" => "My Shopify App One-Time Billing", - "amount" => 5.0, - "currencyCode" => "USD", // Currently only supports USD - "interval" => EnsureBilling::INTERVAL_ONE_TIME, + 'chargeName' => 'My Shopify App One-Time Billing', + 'amount' => 5.0, + 'currencyCode' => 'USD', // Currently only supports USD + 'interval' => EnsureBilling::INTERVAL_ONE_TIME, ], ]; diff --git a/web/routes/web.php b/web/routes/web.php index f8e6c01b6..019137036 100644 --- a/web/routes/web.php +++ b/web/routes/web.php @@ -1,23 +1,10 @@ query("embedded", false) === "1") { - if (env('APP_ENV') === 'production') { - return file_get_contents(public_path('index.html')); - } else { - return file_get_contents(base_path('frontend/index.html')); - } - } else { - return redirect(Utils::getEmbeddedAppUrl($request->query("host", null)) . "/" . $request->path()); - } -})->middleware('shopify.installed'); +Route::fallback(FallbackController::class)->middleware('shopify.installed'); -Route::get('/api/auth', function (Request $request) { - $shop = Utils::sanitizeShopDomain($request->query('shop')); +Route::get('/api/auth', [AuthController::class, 'index']); - // Delete any previously created OAuth sessions that were not completed (don't have an access token) - Session::where('shop', $shop)->where('access_token', null)->delete(); +Route::get('/api/auth/callback', [AuthController::class, 'callback']); - return AuthRedirection::redirect($request); -}); +Route::get('/api/products/count', [ProductController::class, 'count'])->middleware('shopify.auth'); -Route::get('/api/auth/callback', function (Request $request) { - $session = OAuth::callback( - $request->cookie(), - $request->query(), - ['App\Lib\CookieHandler', 'saveShopifyCookie'], - ); +Route::post('/api/products', [ProductController::class, 'store'])->middleware('shopify.auth'); - $host = $request->query('host'); - $shop = Utils::sanitizeShopDomain($request->query('shop')); - - $response = Registry::register('/api/webhooks', Topics::APP_UNINSTALLED, $shop, $session->getAccessToken()); - if ($response->isSuccess()) { - Log::debug("Registered APP_UNINSTALLED webhook for shop $shop"); - } else { - Log::error( - "Failed to register APP_UNINSTALLED webhook for shop $shop with response body: " . - print_r($response->getBody(), true) - ); - } - - $redirectUrl = Utils::getEmbeddedAppUrl($host); - if (Config::get('shopify.billing.required')) { - list($hasPayment, $confirmationUrl) = EnsureBilling::check($session, Config::get('shopify.billing')); - - if (!$hasPayment) { - $redirectUrl = $confirmationUrl; - } - } - - return redirect($redirectUrl); -}); - -Route::get('/api/products/count', function (Request $request) { - /** @var AuthSession */ - $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active - - $client = new Rest($session->getShop(), $session->getAccessToken()); - $result = $client->get('products/count'); - - return response($result->getDecodedBody()); -})->middleware('shopify.auth'); - -Route::post('/api/products', function (Request $request) { - /** @var AuthSession */ - $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active - - $success = $code = $error = null; - try { - ProductCreator::call($session, 5); - $success = true; - $code = 200; - $error = null; - } catch (\Exception $e) { - $success = false; - - if ($e instanceof ShopifyProductCreatorException) { - $code = $e->response->getStatusCode(); - $error = $e->response->getDecodedBody(); - if (array_key_exists("errors", $error)) { - $error = $error["errors"]; - } - } else { - $code = 500; - $error = $e->getMessage(); - } - - Log::error("Failed to create products: $error"); - } finally { - return response()->json(["success" => $success, "error" => $error], $code); - } -})->middleware('shopify.auth'); - -Route::post('/api/webhooks', function (Request $request) { - try { - $topic = $request->header(HttpHeaders::X_SHOPIFY_TOPIC, ''); - - $response = Registry::process($request->header(), $request->getContent()); - if (!$response->isSuccess()) { - Log::error("Failed to process '$topic' webhook: {$response->getErrorMessage()}"); - return response()->json(['message' => "Failed to process '$topic' webhook"], 500); - } - } catch (InvalidWebhookException $e) { - Log::error("Got invalid webhook request for topic '$topic': {$e->getMessage()}"); - return response()->json(['message' => "Got invalid webhook request for topic '$topic'"], 401); - } catch (\Exception $e) { - Log::error("Got an exception when handling '$topic' webhook: {$e->getMessage()}"); - return response()->json(['message' => "Got an exception when handling '$topic' webhook"], 500); - } -}); +Route::post('/api/webhooks', WebhookController::class);