From a5cbaa6ee010d883208251e8d2248ada0c368071 Mon Sep 17 00:00:00 2001 From: NexPB Date: Sat, 15 Mar 2025 21:11:48 +0900 Subject: [PATCH 01/15] feat: optional webhook topics --- lib/shopifex/shops.ex | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/shopifex/shops.ex b/lib/shopifex/shops.ex index 75f42f2..d636c1f 100644 --- a/lib/shopifex/shops.ex +++ b/lib/shopifex/shops.ex @@ -110,7 +110,10 @@ defmodule Shopifex.Shops do Returns a list of webhooks which were created. """ def configure_webhooks(shop) do - with {:ok, current_webhooks} <- get_current_webhooks(shop) do + configured_webhook_topics = Application.get_env(:shopifex, :webhook_topics) + + with {:webhooks, [_ | _]} <- {:webhooks, configured_webhook_topics}, + {:ok, current_webhooks} <- get_current_webhooks(shop) do current_webhook_topics = Enum.map(current_webhooks, & &1.topic) Logger.info( @@ -118,8 +121,7 @@ defmodule Shopifex.Shops do ) current_webhook_topics = MapSet.new(current_webhook_topics) - - topics = MapSet.new(Application.fetch_env!(:shopifex, :webhook_topics)) + topics = MapSet.new(configured_webhook_topics) # Make sure all the required topics are conifgured. subscribe_to_topics = MapSet.difference(topics, current_webhook_topics) @@ -136,6 +138,16 @@ defmodule Shopifex.Shops do acc end end) + else + {:webhooks, nil} -> + Logger.info( + "Missing webhook_topics configuration, assuming webhooks will be managed through the Shopify CLI." + ) + + {:ok, []} + + fallback -> + fallback end end From 45aea4803a6ae6e63d6cd1aaca3711c72c721710 Mon Sep 17 00:00:00 2001 From: NexPB Date: Sun, 16 Mar 2025 13:13:54 +0900 Subject: [PATCH 02/15] feat: single callback url --- .formatter.exs | 1 + .../controllers/auth_controller.ex | 195 ++++++++++++------ lib/shopifex_web/routes.ex | 33 ++- mix.exs | 2 +- mix.lock | 16 +- 5 files changed, 171 insertions(+), 76 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index d2cda26..70c39b3 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # Used by "mix format" [ + import_deps: [:ecto, :ecto_sql, :phoenix], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 27bc604..297e878 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -20,6 +20,23 @@ defmodule ShopifexWeb.AuthController do persisted in the database and webhooks are registered. By default, this function redirects the user to the app within their Shopify admin panel. + ## Example + + @impl true + def after_callback(conn, shop, oauth_state) do + # send yourself an e-mail about shop installation + + # follow default behaviour. + super(conn, shop, oauth_state) + end + """ + @callback after_callback(Plug.Conn.t(), shop(), oauth_state :: String.t()) :: Plug.Conn.t() + + @doc """ + An optional callback called after the installation is completed, the shop is + persisted in the database and webhooks are registered. By default, this function + redirects the user to the app within their Shopify admin panel. + ## Example @impl true @@ -55,17 +72,27 @@ defmodule ShopifexWeb.AuthController do ## Example @impl true - def insert_shop(shop) do - # make sure there is only one store in the database because we don't have - # a unique index on the url column for some reason. + def insert_shop(shop_params) do + # Override the default behaviour, make sure to call `super` at some point + super(shop_params) + end - case Shopifex.Shops.get_shop_by_url(shop.url) do - nil -> super(shop) - shop -> shop - end + """ + @callback insert_shop(params :: shop()) :: shop() + + @doc """ + An optional callback which is called after the shop data has been retrieved from + Shopify API. This function should persist the shop data and return a shop record. + + ## Example + + @impl true + def update_shop(shop, shop_params) do + super(shop, shop_params) end + """ - @callback insert_shop(shop()) :: shop() + @callback update_shop(shop(), params :: map()) :: shop() @doc """ An optional callback which you can use to override how your app is rendered on @@ -77,14 +104,26 @@ defmodule ShopifexWeb.AuthController do """ @callback auth(conn :: Plug.Conn.t(), params :: Plug.Conn.params()) :: Plug.Conn.t() - @optional_callbacks after_install: 3, after_update: 3, insert_shop: 1, auth: 2 + @optional_callbacks after_callback: 3, + after_install: 3, + after_update: 3, + insert_shop: 1, + update_shop: 2, + auth: 2 defmacro __using__(_opts) do quote do - @behaviour ShopifexWeb.AuthController + import ShopifexWeb.AuthController, + only: [ + build_external_url: 1, + build_external_url: 2, + http_post_oauth: 2 + ] require Logger + @behaviour ShopifexWeb.AuthController + @impl ShopifexWeb.AuthController def auth(conn, _) do path_prefix = Application.get_env(:shopifex, :path_prefix, "") @@ -128,42 +167,60 @@ defmodule ShopifexWeb.AuthController do end @impl ShopifexWeb.AuthController - def after_install(conn, shop, _state) do - shop_url = Shopifex.Shops.get_url(shop) - api_key = Application.fetch_env!(:shopifex, :api_key) + def insert_shop(shop) do + Shopifex.Shops.create_shop(shop) + end - url = build_external_url(["https://", shop_url, "/admin/apps", api_key]) - redirect(conn, external: url) + @impl ShopifexWeb.AuthController + def after_callback(conn, shop, _state) do + redirect_to_shopify_admin(conn, shop) + end + + def callback(conn, %{"code" => code, "shop" => shop_url} = params) do + state = Map.get(params, "state", "") + + case http_post_oauth(shop_url, code) do + {:ok, response} -> + params = + response.body + |> Jason.decode!(keys: :atoms) + |> Map.put(:url, shop_url) + |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) + + shop = + case Shopifex.Shops.get_shop_by_url(shop_url) do + nil -> + insert_shop(params) + + %_{} = shop -> + params = Map.drop(params, :url) + update_shop(shop, params) + end + + Shopifex.Shops.configure_webhooks(shop) + + after_callback(conn, shop, state) + + error -> + raise Shopifex.InstallError, message: "Installation failed for shop #{shop_url}" + end end @impl ShopifexWeb.AuthController - def insert_shop(shop) do - Shopifex.Shops.create_shop(shop) + def after_install(conn, shop, _state) do + redirect_to_shopify_admin(conn, shop) end def install(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - url = build_external_url(["https://", shop_url, "/admin/oauth/access_token"]) - case( - HTTPoison.post( - url, - Jason.encode!(%{ - client_id: Application.fetch_env!(:shopifex, :api_key), - client_secret: Application.fetch_env!(:shopifex, :secret), - code: code - }), - "Content-Type": "application/json", - Accept: "application/json" - ) - ) do + case http_post_oauth(shop_url, code) do {:ok, response} -> params = response.body |> Jason.decode!(keys: :atoms) |> Map.put(:url, shop_url) - - params = Map.put(params, Shopifex.Shops.get_scope_field(), params[:scope]) + |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) shop = insert_shop(params) @@ -172,59 +229,79 @@ defmodule ShopifexWeb.AuthController do after_install(conn, shop, state) error -> - raise(Shopifex.InstallError, message: "Installation failed for shop #{shop_url}") + raise Shopifex.InstallError, message: "Installation failed for shop #{shop_url}" end end @impl ShopifexWeb.AuthController - def after_update(conn, shop, _state) do - shop_url = Shopifex.Shops.get_url(shop) - api_key = Application.fetch_env!(:shopifex, :api_key) + def update_shop(%_{} = shop, %{} = params) do + Shopifex.Shops.update_shop(shop, params) + end - url = build_external_url(["https://", shop_url, "/admin/apps/", api_key]) - redirect(conn, external: url) + @impl ShopifexWeb.AuthController + def after_update(conn, shop, _state) do + redirect_to_shopify_admin(conn, shop) end def update(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - url = build_external_url(["https://", shop_url, "/admin/oauth/access_token"]) - case( - HTTPoison.post( - url, - Jason.encode!(%{ - client_id: Application.fetch_env!(:shopifex, :api_key), - client_secret: Application.fetch_env!(:shopifex, :secret), - code: code - }), - "Content-Type": "application/json", - Accept: "application/json" - ) - ) do + case http_post_oauth(shop_url, code) do {:ok, response} -> - params = Jason.decode!(response.body, keys: :atoms) - - params = Map.put(params, Shopifex.Shops.get_scope_field(), params[:scope]) + params = + response.body + |> Jason.decode!(keys: :atoms) + |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) shop = shop_url |> Shopifex.Shops.get_shop_by_url() - |> Shopifex.Shops.update_shop(params) + |> update_shop(params) Shopifex.Shops.configure_webhooks(shop) after_update(conn, shop, state) error -> - raise(Shopifex.UpdateError, message: "Update failed for shop #{shop_url}") + raise Shopifex.UpdateError, message: "Update failed for shop #{shop_url}" end end - defoverridable after_install: 3, after_update: 3, insert_shop: 1, auth: 2 + defoverridable after_callback: 3, + after_install: 3, + after_update: 3, + insert_shop: 1, + update_shop: 2, + auth: 2 - defp build_external_url(path, query_params \\ %{}) do - Path.join(path) <> "?" <> URI.encode_query(query_params) + defp redirect_to_shopify_admin(conn, shop) do + shop_url = Shopifex.Shops.get_url(shop) + api_key = Application.fetch_env!(:shopifex, :api_key) + + url = build_external_url(["https://", shop_url, "/admin/apps", api_key]) + redirect(conn, external: url) end end end + + def http_post_oauth(shop_domain, code) do + url = build_external_url(["https://", shop_domain, "/admin/oauth/access_token"]) + + headers = [ + "Content-Type": "application/json", + Accept: "application/json" + ] + + body = %{ + client_id: Application.fetch_env!(:shopifex, :api_key), + client_secret: Application.fetch_env!(:shopifex, :secret), + code: code + } + + HTTPoison.post(url, Jason.encode!(body), headers) + end + + def build_external_url(path, query_params \\ %{}) do + Path.join(path) <> "?" <> URI.encode_query(query_params) + end end diff --git a/lib/shopifex_web/routes.ex b/lib/shopifex_web/routes.ex index 1d25694..c805c66 100644 --- a/lib/shopifex_web/routes.ex +++ b/lib/shopifex_web/routes.ex @@ -101,22 +101,39 @@ defmodule ShopifexWeb.Routes do end end - defmacro auth_routes(controller \\ ShopifexWeb.AuthController) do + @doc """ + Injects the standard Shopify auth routes which your app will need. + + ## Options + + - `:cli` - If true, the callback route will be `/auth/callback` instead of `/auth/install`. + + """ + defmacro auth_routes(controller \\ ShopifexWeb.AuthController, opts \\ []) do + cli? = Keyword.get(opts, :cli, false) + quote do scope "/auth" do - pipe_through([:shopifex_browser, :shopify_session]) - get("/", unquote(controller), :auth) + pipe_through [:shopifex_browser, :shopify_session] + + get "/", unquote(controller), :auth end scope "/auth" do - pipe_through([:shopifex_browser, :validate_install_hmac]) - get("/install", unquote(controller), :install) - get("/update", unquote(controller), :update) + pipe_through [:shopifex_browser, :validate_install_hmac] + + if unquote(cli?) do + get "/callback", unquote(controller), :callback + else + get "/install", unquote(controller), :install + get "/update", unquote(controller), :update + end end scope "/initialize-installation" do - pipe_through([:shopifex_browser]) - get("/", unquote(controller), :initialize_installation) + pipe_through [:shopifex_browser] + + get "/", unquote(controller), :initialize_installation end end end diff --git a/mix.exs b/mix.exs index 6368937..1e93e0c 100644 --- a/mix.exs +++ b/mix.exs @@ -54,7 +54,7 @@ defmodule Shopifex.MixProject do {:phoenix_html_helpers, "~> 1.0"}, {:phoenix_ecto, "~> 4.4"}, {:phoenix_view, "~> 2.0"}, - {:ecto_sql, "~> 3.7"}, + {:ecto_sql, "~> 3.12"}, {:postgrex, ">= 0.0.0"}, {:phoenix_html, ">= 4.0.0"}, {:gettext, "~> 0.11"}, diff --git a/mix.lock b/mix.lock index a16f27a..c7181ed 100644 --- a/mix.lock +++ b/mix.lock @@ -6,12 +6,12 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, - "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, - "ecto_sql": {:hex, :ecto_sql, "3.7.1", "8de624ef50b2a8540252d8c60506379fbbc2707be1606853df371cf53df5d053", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b42a32e2ce92f64aba5c88617891ab3b0ba34f3f3a503fa20009eae1a401c81"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, "ex_doc": {:hex, :ex_doc, "0.25.1", "4b736fa38dc76488a937e5ef2944f5474f3eff921de771b25371345a8dc810bc", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3200b0a69ddb2028365281fbef3753ea9e728683863d8cdaa96580925c891f67"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, @@ -21,7 +21,7 @@ "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, @@ -45,12 +45,12 @@ "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, - "postgrex": {:hex, :postgrex, "0.15.13", "7794e697481799aee8982688c261901de493eb64451feee6ea58207d7266d54a", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3ffb76e1a97cfefe5c6a95632a27ffb67f28871c9741fb585f9d1c3cd2af70f1"}, + "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "react_phoenix": {:hex, :react_phoenix, "1.2.0", "42f4f6a7d1006b50f89f2209fc1402e8d6b5cca34ca0fbfcdc0c43619db1ad4a", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.11", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "aab3a7ba35e68776da5d52817b1044b130e13740b72db9faa8a919dfce68be66"}, "shopify": {:hex, :shopify, "0.4.0", "bb53bba95d56a047c5cd4b1a794ef1b6408fb71268d1782bb42e7af15f25a546", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "7c98d571a58c031e8540416140f3d5ad5b161ee93f45b82ecefa13e27065ae6d"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.0", "f6bbce90226121d62a0715bca7c986c5e43de0ccc9475d79c55381d1796368cc", [:mix], [], "hexpm", "b51ac706df8a7a48a2c622ee02d09d68be8c40418698ffa909d73ae207eb5fb8"}, "websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"}, From d2083055b1b0f3a595b07115eabf585fc024f1ec Mon Sep 17 00:00:00 2001 From: NexPB Date: Sun, 16 Mar 2025 13:27:45 +0900 Subject: [PATCH 03/15] fix: params to atom keys --- lib/shopifex_web/controllers/auth_controller.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 297e878..8680ab2 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -181,9 +181,10 @@ defmodule ShopifexWeb.AuthController do case http_post_oauth(shop_url, code) do {:ok, response} -> + params = Jason.decode!(response.body, keys: :atoms) + params = - response.body - |> Jason.decode!(keys: :atoms) + params |> Map.put(:url, shop_url) |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) @@ -216,9 +217,10 @@ defmodule ShopifexWeb.AuthController do case http_post_oauth(shop_url, code) do {:ok, response} -> + params = Jason.decode!(params, keys: :atoms) + params = - response.body - |> Jason.decode!(keys: :atoms) + params |> Map.put(:url, shop_url) |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) @@ -248,10 +250,8 @@ defmodule ShopifexWeb.AuthController do case http_post_oauth(shop_url, code) do {:ok, response} -> - params = - response.body - |> Jason.decode!(keys: :atoms) - |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) + params = Jason.decode!(params, keys: :atoms) + params = Map.put(params, Shopifex.Shops.get_scope_field(), params[:scope]) shop = shop_url From 8debd4a8f18f1ee3da2955574debc50a4246a915 Mon Sep 17 00:00:00 2001 From: NexPB Date: Sun, 16 Mar 2025 13:50:35 +0900 Subject: [PATCH 04/15] fix: use recommended redirect --- .../controllers/auth_controller.ex | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 8680ab2..f356dc3 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -30,7 +30,7 @@ defmodule ShopifexWeb.AuthController do super(conn, shop, oauth_state) end """ - @callback after_callback(Plug.Conn.t(), shop(), oauth_state :: String.t()) :: Plug.Conn.t() + @callback after_callback(Plug.Conn.t(), shop(), params :: map()) :: Plug.Conn.t() @doc """ An optional callback called after the installation is completed, the shop is @@ -172,35 +172,39 @@ defmodule ShopifexWeb.AuthController do end @impl ShopifexWeb.AuthController - def after_callback(conn, shop, _state) do - redirect_to_shopify_admin(conn, shop) + def after_callback(conn, _shop, %{} = params) do + redirect_to_shopify_host(conn, params.host) end def callback(conn, %{"code" => code, "shop" => shop_url} = params) do + host = Map.fetch!(params, "host") state = Map.get(params, "state", "") case http_post_oauth(shop_url, code) do {:ok, response} -> - params = Jason.decode!(response.body, keys: :atoms) + args = Jason.decode!(response.body, keys: :atoms) - params = + args = params |> Map.put(:url, shop_url) - |> Map.put(Shopifex.Shops.get_scope_field(), params[:scope]) + |> Map.put(Shopifex.Shops.get_scope_field(), args[:scope]) shop = case Shopifex.Shops.get_shop_by_url(shop_url) do nil -> - insert_shop(params) + insert_shop(args) %_{} = shop -> - params = Map.drop(params, :url) - update_shop(shop, params) + args = Map.drop(args, :url) + update_shop(shop, args) end Shopifex.Shops.configure_webhooks(shop) - after_callback(conn, shop, state) + after_callback(conn, shop, %{ + state: state, + host: host + }) error -> raise Shopifex.InstallError, message: "Installation failed for shop #{shop_url}" @@ -281,6 +285,14 @@ defmodule ShopifexWeb.AuthController do url = build_external_url(["https://", shop_url, "/admin/apps", api_key]) redirect(conn, external: url) end + + defp redirect_to_shopify_host(conn, base64_host) do + api_key = Application.fetch_env!(:shopifex, :api_key) + shop_url = Base.decode64!(base64_host) + + url = build_external_url(["https://", shop_url, "/apps", api_key]) + redirect(conn, external: url) + end end end From 3b9ce3ac62e55ccd020717217ba9eaa2f64d4ab0 Mon Sep 17 00:00:00 2001 From: NexPB Date: Sun, 16 Mar 2025 13:53:53 +0900 Subject: [PATCH 05/15] fix: args rename --- lib/shopifex_web/controllers/auth_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index f356dc3..38849d8 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -185,7 +185,7 @@ defmodule ShopifexWeb.AuthController do args = Jason.decode!(response.body, keys: :atoms) args = - params + args |> Map.put(:url, shop_url) |> Map.put(Shopifex.Shops.get_scope_field(), args[:scope]) From 143571d74d6ebc4c69ce2060c23f19b4455451e3 Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 19 Mar 2025 16:23:45 +0900 Subject: [PATCH 06/15] refactor: if else before quote --- lib/shopifex_web/routes.ex | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/shopifex_web/routes.ex b/lib/shopifex_web/routes.ex index c805c66..f1292c9 100644 --- a/lib/shopifex_web/routes.ex +++ b/lib/shopifex_web/routes.ex @@ -113,27 +113,35 @@ defmodule ShopifexWeb.Routes do cli? = Keyword.get(opts, :cli, false) quote do + scope "/initialize-installation" do + pipe_through [:shopifex_browser] + + get "/", unquote(controller), :initialize_installation + end + scope "/auth" do pipe_through [:shopifex_browser, :shopify_session] get "/", unquote(controller), :auth end + end - scope "/auth" do - pipe_through [:shopifex_browser, :validate_install_hmac] + if cli? do + quote do + scope "/auth" do + pipe_through [:shopifex_browser, :shopify_session] - if unquote(cli?) do get "/callback", unquote(controller), :callback - else - get "/install", unquote(controller), :install - get "/update", unquote(controller), :update end end + else + quote do + scope "/auth" do + pipe_through [:shopifex_browser, :validate_install_hmac] - scope "/initialize-installation" do - pipe_through [:shopifex_browser] - - get "/", unquote(controller), :initialize_installation + get "/install", unquote(controller), :install + get "/update", unquote(controller), :update + end end end end From c3f040f7c135f9d045a4967a4377e9e3102791d0 Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 19 Mar 2025 16:30:28 +0900 Subject: [PATCH 07/15] fix: 143571d74d6ebc4c69ce2060c23f19b4455451e3 --- lib/shopifex_web/routes.ex | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/shopifex_web/routes.ex b/lib/shopifex_web/routes.ex index f1292c9..733e8b7 100644 --- a/lib/shopifex_web/routes.ex +++ b/lib/shopifex_web/routes.ex @@ -112,22 +112,25 @@ defmodule ShopifexWeb.Routes do defmacro auth_routes(controller \\ ShopifexWeb.AuthController, opts \\ []) do cli? = Keyword.get(opts, :cli, false) - quote do - scope "/initialize-installation" do - pipe_through [:shopifex_browser] + base_routes = + quote do + scope "/initialize-installation" do + pipe_through [:shopifex_browser] - get "/", unquote(controller), :initialize_installation - end + get "/", unquote(controller), :initialize_installation + end - scope "/auth" do - pipe_through [:shopifex_browser, :shopify_session] + scope "/auth" do + pipe_through [:shopifex_browser, :shopify_session] - get "/", unquote(controller), :auth + get "/", unquote(controller), :auth + end end - end if cli? do quote do + unquote(base_routes) + scope "/auth" do pipe_through [:shopifex_browser, :shopify_session] @@ -136,6 +139,8 @@ defmodule ShopifexWeb.Routes do end else quote do + unquote(base_routes) + scope "/auth" do pipe_through [:shopifex_browser, :validate_install_hmac] From e627aef930768c3676a06290637bf8b038c9f447 Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 19 Mar 2025 18:24:11 +0900 Subject: [PATCH 08/15] fix: use the correct plug in callback --- lib/shopifex_web/routes.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shopifex_web/routes.ex b/lib/shopifex_web/routes.ex index 733e8b7..0471809 100644 --- a/lib/shopifex_web/routes.ex +++ b/lib/shopifex_web/routes.ex @@ -132,7 +132,7 @@ defmodule ShopifexWeb.Routes do unquote(base_routes) scope "/auth" do - pipe_through [:shopifex_browser, :shopify_session] + pipe_through [:shopifex_browser, :validate_install_hmac] get "/callback", unquote(controller), :callback end From 638534a4400f15f354f25c54da8df11d6861ba45 Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 19 Mar 2025 18:25:16 +0900 Subject: [PATCH 09/15] feat: redirect using shopify app bridge --- .../controllers/auth_controller.ex | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 38849d8..0ec80a0 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -117,6 +117,7 @@ defmodule ShopifexWeb.AuthController do only: [ build_external_url: 1, build_external_url: 2, + embedded_app_uri: 1, http_post_oauth: 2 ] @@ -145,17 +146,31 @@ defmodule ShopifexWeb.AuthController do }) end + # Escape the iframe for embedded apps + # https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#check-for-and-escape-the-iframe-embedded-apps-only + redirect_to = fn external_url -> + if Map.get(params, "embedded") == "1" do + conn + |> put_view(ShopifexWeb.PageView) + |> put_layout({ShopifexWeb.LayoutView, "app.html"}) + |> render("redirect.html", redirect_location: external_url, message: "") + |> halt() + else + redirect(conn, external: external_url) + end + end + # check if store is in the system already: case Shopifex.Shops.get_shop_by_url(shop_url) do nil -> Logger.info("Initiating shop installation for #{shop_url}") install_url = url.(Application.fetch_env!(:shopifex, :redirect_uri)) - redirect(conn, external: install_url) + redirect_to.(install_url) shop -> - Logger.info("Initiating shop reinstallation for #{shop_url}") + Logger.info("Initiating shop re-installation for #{shop_url}") reinstall_url = url.(Application.fetch_env!(:shopifex, :reinstall_uri)) - redirect(conn, external: reinstall_url) + redirect_to.(reinstall_url) end else conn @@ -172,8 +187,10 @@ defmodule ShopifexWeb.AuthController do end @impl ShopifexWeb.AuthController - def after_callback(conn, _shop, %{} = params) do - redirect_to_shopify_host(conn, params.host) + def after_callback(conn, shop, %{} = params) do + conn + |> Plug.Conn.assign(:shop, shop) + |> redirect_to_app(params.host) end def callback(conn, %{"code" => code, "shop" => shop_url} = params) do @@ -286,16 +303,26 @@ defmodule ShopifexWeb.AuthController do redirect(conn, external: url) end - defp redirect_to_shopify_host(conn, base64_host) do - api_key = Application.fetch_env!(:shopifex, :api_key) - shop_url = Base.decode64!(base64_host) + defp redirect_to_app(conn, base64_host) do + host = Base.decode64!(base64_host, padding: false) - url = build_external_url(["https://", shop_url, "/apps", api_key]) - redirect(conn, external: url) + embedded_app_url = + host + |> embedded_app_uri() + |> URI.append_path("/") + |> URI.to_string() + + redirect(conn, external: embedded_app_url) end end end + @spec embedded_app_uri(String.t()) :: URI.t() + def embedded_app_uri(host) do + api_key = Application.fetch_env!(:shopifex, :api_key) + URI.new!("https://#{host}/apps/#{api_key}") + end + def http_post_oauth(shop_domain, code) do url = build_external_url(["https://", shop_domain, "/admin/oauth/access_token"]) From 05338df1a9ef37ddab897aa7d76ae9bb9c60c5af Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 19 Mar 2025 20:35:11 +0900 Subject: [PATCH 10/15] refactor: oauth flow --- lib/shopifex/oauth.ex | 68 +++++++++++ lib/shopifex/plug.ex | 4 +- lib/shopifex/plug/shopify_session.ex | 18 +-- .../controllers/auth_controller.ex | 111 +++++------------- 4 files changed, 107 insertions(+), 94 deletions(-) create mode 100644 lib/shopifex/oauth.ex diff --git a/lib/shopifex/oauth.ex b/lib/shopifex/oauth.ex new file mode 100644 index 0000000..d070cc0 --- /dev/null +++ b/lib/shopifex/oauth.ex @@ -0,0 +1,68 @@ +defmodule Shopifex.OAuth do + @moduledoc """ + Shopify OAuth related functions. + """ + alias Plug.Conn + + @doc """ + Redirects the user to the Shopify OAuth page. + Shopify docs: + """ + @spec redirect_to_oauth(Conn.t(), String.t(), Keyword.t()) :: Conn.t() + def redirect_to_oauth(%Conn{} = conn, shop_url, opts \\ []) do + redirect_uri = + Keyword.get(opts, :redirect_uri, Application.fetch_env!(:shopifex, :redirect_uri)) + + query_params = + URI.encode_query(%{ + client_id: Application.fetch_env!(:shopifex, :api_key), + scope: Application.fetch_env!(:shopifex, :scopes), + redirect_uri: redirect_uri, + state: Keyword.get(opts, :state, "") + }) + + # The installation case and reinstallation case share the same URL, and query parameters, + # except for the value of of the redirect_uri + oauth_url = + "https://#{shop_url}/admin/oauth/authorize" + |> URI.new!() + |> URI.append_query(query_params) + |> URI.to_string() + + # Escape the iframe for embedded apps + # https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#check-for-and-escape-the-iframe-embedded-apps-only + if Map.get(conn.params, "embedded") == "1" do + conn + |> Phoenix.Controller.put_layout(html: {ShopifexWeb.LayoutView, :app}) + |> Phoenix.Controller.put_view(ShopifexWeb.PageView) + |> Phoenix.Controller.render("redirect.html", redirect_location: oauth_url, message: "") + |> Plug.Conn.halt() + else + Phoenix.Controller.redirect(conn, external: oauth_url) + end + end + + @doc """ + Calls the Shopify OAuth endpoint to get the access token. + Shopify docs: + """ + @spec post_access_token(String.t(), String.t()) :: + {:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()} + def post_access_token(shop_domain, code) do + headers = [ + "Content-Type": "application/json", + Accept: "application/json" + ] + + body = %{ + client_id: Application.fetch_env!(:shopifex, :api_key), + client_secret: Application.fetch_env!(:shopifex, :secret), + code: code + } + + "https://#{shop_domain}/admin/oauth/access_token" + |> URI.new!() + |> URI.to_string() + |> HTTPoison.post(Jason.encode!(body), headers) + end +end diff --git a/lib/shopifex/plug.ex b/lib/shopifex/plug.ex index 3355bfe..dfa1897 100644 --- a/lib/shopifex/plug.ex +++ b/lib/shopifex/plug.ex @@ -36,7 +36,9 @@ defmodule Shopifex.Plug do def current_shopify_host(%Plug.Conn{private: %{shopifex: %{shopify_host: shopify_host}}}), do: shopify_host - def current_shopify_host(_), do: nil + def current_shopify_host(conn) do + Map.get(conn.params, "host") + end @doc """ Returns the token for the current session in a plug which has diff --git a/lib/shopifex/plug/shopify_session.ex b/lib/shopifex/plug/shopify_session.ex index e5c73f4..86d3297 100644 --- a/lib/shopifex/plug/shopify_session.ex +++ b/lib/shopifex/plug/shopify_session.ex @@ -38,8 +38,7 @@ defmodule Shopifex.Plug.ShopifySession do received_hmac = Shopifex.Plug.get_hmac(conn) if expected_hmac == received_hmac do - conn - |> do_new_session() + do_new_session(conn) else Logger.info("Invalid HMAC, expected #{expected_hmac}") respond_invalid(conn) @@ -49,7 +48,9 @@ defmodule Shopifex.Plug.ShopifySession do defp do_new_session(conn = %{params: %{"shop" => shop_url}}) do case Shopifex.Shops.get_shop_by_url(shop_url) do nil -> - redirect_to_install(conn, shop_url) + conn + |> redirect(to: "/initialize-installation?#{conn.query_string}") + |> halt() shop -> locale = get_locale(conn) @@ -59,17 +60,6 @@ defmodule Shopifex.Plug.ShopifySession do end end - defp redirect_to_install(conn, shop_url) do - Logger.info("Initiating shop installation for #{shop_url}") - - install_url = - "https://#{shop_url}/admin/oauth/authorize?client_id=#{Application.fetch_env!(:shopifex, :api_key)}&scope=#{Application.fetch_env!(:shopifex, :scopes)}&redirect_uri=#{Application.fetch_env!(:shopifex, :redirect_uri)}" - - conn - |> redirect(external: install_url) - |> halt() - end - defp respond_invalid(%Plug.Conn{private: %{phoenix_format: "json"}} = conn) do conn |> put_status(:forbidden) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 0ec80a0..eb6c15e 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -115,10 +115,7 @@ defmodule ShopifexWeb.AuthController do quote do import ShopifexWeb.AuthController, only: [ - build_external_url: 1, - build_external_url: 2, - embedded_app_uri: 1, - http_post_oauth: 2 + embedded_app_uri: 1 ] require Logger @@ -128,49 +125,26 @@ defmodule ShopifexWeb.AuthController do @impl ShopifexWeb.AuthController def auth(conn, _) do path_prefix = Application.get_env(:shopifex, :path_prefix, "") - - conn - |> redirect(to: path_prefix <> "/?token=" <> Guardian.Plug.current_token(conn)) + redirect(conn, to: path_prefix <> "/?token=" <> Guardian.Plug.current_token(conn)) end def initialize_installation(conn, %{"shop" => shop_url} = params) do if Regex.match?(~r/^.*\.myshopify\.com/, shop_url) do - # The installation case and reinstallation case share the same URL, and query parameters, - # except for the value of of the redirect_uri - url = fn redirect_uri -> - build_external_url(["https://", shop_url, "/admin/oauth/authorize"], %{ - client_id: Application.fetch_env!(:shopifex, :api_key), - scope: Application.fetch_env!(:shopifex, :scopes), - redirect_uri: redirect_uri, - state: params["state"] - }) - end - - # Escape the iframe for embedded apps - # https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#check-for-and-escape-the-iframe-embedded-apps-only - redirect_to = fn external_url -> - if Map.get(params, "embedded") == "1" do - conn - |> put_view(ShopifexWeb.PageView) - |> put_layout({ShopifexWeb.LayoutView, "app.html"}) - |> render("redirect.html", redirect_location: external_url, message: "") - |> halt() - else - redirect(conn, external: external_url) - end - end - # check if store is in the system already: case Shopifex.Shops.get_shop_by_url(shop_url) do nil -> Logger.info("Initiating shop installation for #{shop_url}") - install_url = url.(Application.fetch_env!(:shopifex, :redirect_uri)) - redirect_to.(install_url) + + Shopifex.OAuth.redirect_to_oauth(conn, shop_url, + redirect_uri: Application.fetch_env!(:shopifex, :redirect_uri) + ) shop -> Logger.info("Initiating shop re-installation for #{shop_url}") - reinstall_url = url.(Application.fetch_env!(:shopifex, :reinstall_uri)) - redirect_to.(reinstall_url) + + Shopifex.OAuth.redirect_to_oauth(conn, shop_url, + redirect_uri: Application.fetch_env!(:shopifex, :reinstall_uri) + ) end else conn @@ -190,14 +164,14 @@ defmodule ShopifexWeb.AuthController do def after_callback(conn, shop, %{} = params) do conn |> Plug.Conn.assign(:shop, shop) - |> redirect_to_app(params.host) + |> redirect_to_app({:host, params.host}) end def callback(conn, %{"code" => code, "shop" => shop_url} = params) do host = Map.fetch!(params, "host") state = Map.get(params, "state", "") - case http_post_oauth(shop_url, code) do + case Shopifex.OAuth.post_access_token(shop_url, code) do {:ok, response} -> args = Jason.decode!(response.body, keys: :atoms) @@ -230,13 +204,13 @@ defmodule ShopifexWeb.AuthController do @impl ShopifexWeb.AuthController def after_install(conn, shop, _state) do - redirect_to_shopify_admin(conn, shop) + redirect_to_app(conn, {:shop, shop}) end def install(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - case http_post_oauth(shop_url, code) do + case Shopifex.OAuth.post_access_token(shop_url, code) do {:ok, response} -> params = Jason.decode!(params, keys: :atoms) @@ -263,13 +237,13 @@ defmodule ShopifexWeb.AuthController do @impl ShopifexWeb.AuthController def after_update(conn, shop, _state) do - redirect_to_shopify_admin(conn, shop) + redirect_to_app(conn, {:shop, shop}) end def update(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - case http_post_oauth(shop_url, code) do + case Shopifex.OAuth.post_access_token(shop_url, code) do {:ok, response} -> params = Jason.decode!(params, keys: :atoms) params = Map.put(params, Shopifex.Shops.get_scope_field(), params[:scope]) @@ -288,32 +262,32 @@ defmodule ShopifexWeb.AuthController do end end - defoverridable after_callback: 3, - after_install: 3, - after_update: 3, - insert_shop: 1, - update_shop: 2, - auth: 2 - - defp redirect_to_shopify_admin(conn, shop) do - shop_url = Shopifex.Shops.get_url(shop) - api_key = Application.fetch_env!(:shopifex, :api_key) - - url = build_external_url(["https://", shop_url, "/admin/apps", api_key]) - redirect(conn, external: url) + defp redirect_to_app(conn, {:shop, %_{} = shop}) do + host_url = Shopifex.Shops.get_url(shop) + redirect_to_app(conn, host_url) end - defp redirect_to_app(conn, base64_host) do - host = Base.decode64!(base64_host, padding: false) + defp redirect_to_app(conn, {:host, base64_host}) do + host_url = Base.decode64!(base64_host, padding: false) + redirect_to_app(conn, host_url) + end + defp redirect_to_app(conn, host_url) do embedded_app_url = - host + host_url |> embedded_app_uri() |> URI.append_path("/") |> URI.to_string() redirect(conn, external: embedded_app_url) end + + defoverridable after_callback: 3, + after_install: 3, + after_update: 3, + insert_shop: 1, + update_shop: 2, + auth: 2 end end @@ -322,25 +296,4 @@ defmodule ShopifexWeb.AuthController do api_key = Application.fetch_env!(:shopifex, :api_key) URI.new!("https://#{host}/apps/#{api_key}") end - - def http_post_oauth(shop_domain, code) do - url = build_external_url(["https://", shop_domain, "/admin/oauth/access_token"]) - - headers = [ - "Content-Type": "application/json", - Accept: "application/json" - ] - - body = %{ - client_id: Application.fetch_env!(:shopifex, :api_key), - client_secret: Application.fetch_env!(:shopifex, :secret), - code: code - } - - HTTPoison.post(url, Jason.encode!(body), headers) - end - - def build_external_url(path, query_params \\ %{}) do - Path.join(path) <> "?" <> URI.encode_query(query_params) - end end From 20992c76284370a65f93d9518be06686922b2bd3 Mon Sep 17 00:00:00 2001 From: NexPB Date: Thu, 20 Mar 2025 21:23:29 +0900 Subject: [PATCH 11/15] refactor: pr feedback - move web-layer fns --- lib/shopifex/oauth.ex | 30 +++++-------------- .../controllers/auth_controller.ex | 24 +++++++++++---- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/lib/shopifex/oauth.ex b/lib/shopifex/oauth.ex index d070cc0..326ee9b 100644 --- a/lib/shopifex/oauth.ex +++ b/lib/shopifex/oauth.ex @@ -2,14 +2,13 @@ defmodule Shopifex.OAuth do @moduledoc """ Shopify OAuth related functions. """ - alias Plug.Conn @doc """ - Redirects the user to the Shopify OAuth page. + Returns an url to redirect the user to the Shopify OAuth page. Shopify docs: """ - @spec redirect_to_oauth(Conn.t(), String.t(), Keyword.t()) :: Conn.t() - def redirect_to_oauth(%Conn{} = conn, shop_url, opts \\ []) do + @spec oauth_redirect_url(String.t(), Keyword.t()) :: String.t() + def oauth_redirect_url(shop_url, opts \\ []) do redirect_uri = Keyword.get(opts, :redirect_uri, Application.fetch_env!(:shopifex, :redirect_uri)) @@ -21,25 +20,10 @@ defmodule Shopifex.OAuth do state: Keyword.get(opts, :state, "") }) - # The installation case and reinstallation case share the same URL, and query parameters, - # except for the value of of the redirect_uri - oauth_url = - "https://#{shop_url}/admin/oauth/authorize" - |> URI.new!() - |> URI.append_query(query_params) - |> URI.to_string() - - # Escape the iframe for embedded apps - # https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#check-for-and-escape-the-iframe-embedded-apps-only - if Map.get(conn.params, "embedded") == "1" do - conn - |> Phoenix.Controller.put_layout(html: {ShopifexWeb.LayoutView, :app}) - |> Phoenix.Controller.put_view(ShopifexWeb.PageView) - |> Phoenix.Controller.render("redirect.html", redirect_location: oauth_url, message: "") - |> Plug.Conn.halt() - else - Phoenix.Controller.redirect(conn, external: oauth_url) - end + "https://#{shop_url}/admin/oauth/authorize" + |> URI.new!() + |> URI.append_query(query_params) + |> URI.to_string() end @doc """ diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index eb6c15e..480384c 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -113,10 +113,7 @@ defmodule ShopifexWeb.AuthController do defmacro __using__(_opts) do quote do - import ShopifexWeb.AuthController, - only: [ - embedded_app_uri: 1 - ] + import ShopifexWeb.AuthController, only: [embedded_app_uri: 1] require Logger @@ -135,14 +132,14 @@ defmodule ShopifexWeb.AuthController do nil -> Logger.info("Initiating shop installation for #{shop_url}") - Shopifex.OAuth.redirect_to_oauth(conn, shop_url, + redirect_to_oauth(conn, shop_url, redirect_uri: Application.fetch_env!(:shopifex, :redirect_uri) ) shop -> Logger.info("Initiating shop re-installation for #{shop_url}") - Shopifex.OAuth.redirect_to_oauth(conn, shop_url, + redirect_to_oauth(conn, shop_url, redirect_uri: Application.fetch_env!(:shopifex, :reinstall_uri) ) end @@ -282,6 +279,21 @@ defmodule ShopifexWeb.AuthController do redirect(conn, external: embedded_app_url) end + defp redirect_to_oauth(conn, shop_url, opts \\ []) do + oauth_url = Shopifex.OAuth.oauth_redirect_url(shop_url, opts) + + # Escape the iframe for embedded apps + # https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#check-for-and-escape-the-iframe-embedded-apps-only + if Map.get(conn.params, "embedded") == "1" do + conn + |> put_layout(html: {ShopifexWeb.LayoutView, :app}) + |> put_view(ShopifexWeb.PageView) + |> render("redirect.html", redirect_location: oauth_url, message: "") + else + redirect(conn, external: oauth_url) + end + end + defoverridable after_callback: 3, after_install: 3, after_update: 3, From 8d3da884aaa50a407d2c82c829187366cd032c44 Mon Sep 17 00:00:00 2001 From: NexPB Date: Wed, 26 Mar 2025 22:12:42 +0900 Subject: [PATCH 12/15] chore: readme shopify cli setup --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/README.md b/README.md index 1e73f7c..8301285 100644 --- a/README.md +++ b/README.md @@ -348,3 +348,118 @@ window.app = createApp({ const sessionData = await instance.get('/api/initialize'); // Now you will have access to the current shop and Bob's-yer-uncle! ``` + +## Using Shopifex with Shopify CLI + +Use the Shopify CLI to simplify the setup process and avoid manual configuration of URLs and environment variables. + +For more details on using the Shopify CLI, refer to the [Shopify CLI documentation](https://shopify.dev/docs/api/shopify-cli). + +### Folder Structure + +Your project structure should look something like this: + +```md +- phx/ # Phoenix app +- shopify/ # Shopify cli (`shopify app init`) generated contents +``` + +### Shopify CLI Configuration + +1. Modify the existing `shopify.app.toml` file in the `shopify/` folder to include the following configuration. This ensures that Cloudflare tunnels are automatically updated during development: + +```toml +[build] +automatically_update_urls_on_dev = true +``` + +2. Create a `shopify.web.toml` file in the `shopify/web/` folder. This ensures that the Shopify CLI starts your Phoenix app when running `shopify app dev`. + +```toml +name = "phx" +roles = ["backend", "frontend"] +port = 4000 + +webhooks_path = "/shopify/webhooks" + +[commands] +dev = "./start.sh" +``` + +3. Add the following `start.sh` script to the `shopify/web/` folder to start your Phoenix server: + +```bash +#!/bin/bash + +# Navigate to the phoenix directory +cd ./../phx + +# Available environment variables +# https://shopify.dev/docs/apps/build/cli-for-apps/migrate-to-latest-cli#provided-variables +echo "[start.sh] Served from: '$HOST'" +echo "[start.sh] Enabled API scopes: '$SCOPES'" + +# Start the Phoenix server +mix phx.server +``` + +### Configuring Phoenix with Shopify CLI + +The Shopify CLI sets certain environment variables that can be used to configure your Phoenix app dynamically. Update your `runtime.exs` file to use these variables: + +```elixir +# filepath: phx/config/runtime.exs +import Config + +if config_env() != :test do + shopify_api_key = System.get_env("SHOPIFY_API_KEY") + shopify_api_secret = System.get_env("SHOPIFY_API_SECRET") + shopify_api_scopes = System.get_env("SCOPES") + + confs = %{ + "SHOPIFY_API_KEY" => shopify_api_key, + "SHOPIFY_API_SECRET" => shopify_api_secret, + "SCOPES" => shopify_api_scopes + } + + for {conf_key, conf_value} <- confs do + if is_nil(conf_value) do + Logger.warning(""" + environment variable #{conf_key} is missing. + In development this is automatically set when running `shopify app dev` + """) + end + end + + config :shopifex, + api_key: shopify_api_key, + secret: shopify_api_secret, + scopes: shopify_api_scopes +end + +if host = System.get_env("HOST") do + # Support proxy URLs `HOST` is set by the Shopify CLI when running the dev command + {:ok, host_uri} = URI.new(host) + + config :shopifex, + redirect_uri: host_uri |> URI.append_path("/auth/callback") |> URI.to_string(), + reinstall_uri: host_uri |> URI.append_path("/auth/calback") |> URI.to_string(), + webhook_uri: host_uri |> URI.append_path("/shopify/webhooks") |> URI.to_string(), + payment_redirect_uri: host_uri |> URI.append_path("/shopify/payments") |> URI.to_string() +end +``` + +### Starting the Development Environment + +1. Navigate to the `shopify/` folder. +2. Run the following command to start the Shopify development environment: + +```sh +shopify app dev +``` + +This command will: + +- Set up a Cloudflare proxy for your app +- Set environment variables required for your app +- Start your Phoenix server From f53ce4c6c0482ea551469cba498b17b4fcfb4f6b Mon Sep 17 00:00:00 2001 From: NexPB Date: Tue, 1 Apr 2025 20:13:26 +0900 Subject: [PATCH 13/15] refactor: rename fn to exchange_code_for_access_token --- lib/shopifex/oauth.ex | 4 ++-- lib/shopifex_web/controllers/auth_controller.ex | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/shopifex/oauth.ex b/lib/shopifex/oauth.ex index 326ee9b..b7d32ff 100644 --- a/lib/shopifex/oauth.ex +++ b/lib/shopifex/oauth.ex @@ -30,9 +30,9 @@ defmodule Shopifex.OAuth do Calls the Shopify OAuth endpoint to get the access token. Shopify docs: """ - @spec post_access_token(String.t(), String.t()) :: + @spec exchange_code_for_access_token(String.t(), String.t()) :: {:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()} - def post_access_token(shop_domain, code) do + def exchange_code_for_access_token(shop_domain, code) do headers = [ "Content-Type": "application/json", Accept: "application/json" diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 480384c..475a269 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -168,7 +168,7 @@ defmodule ShopifexWeb.AuthController do host = Map.fetch!(params, "host") state = Map.get(params, "state", "") - case Shopifex.OAuth.post_access_token(shop_url, code) do + case Shopifex.OAuth.exchange_code_for_access_token(shop_url, code) do {:ok, response} -> args = Jason.decode!(response.body, keys: :atoms) @@ -207,7 +207,7 @@ defmodule ShopifexWeb.AuthController do def install(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - case Shopifex.OAuth.post_access_token(shop_url, code) do + case Shopifex.OAuth.exchange_code_for_access_token(shop_url, code) do {:ok, response} -> params = Jason.decode!(params, keys: :atoms) @@ -240,7 +240,7 @@ defmodule ShopifexWeb.AuthController do def update(conn, %{"code" => code, "shop" => shop_url} = params) do state = Map.get(params, "state", "") - case Shopifex.OAuth.post_access_token(shop_url, code) do + case Shopifex.OAuth.exchange_code_for_access_token(shop_url, code) do {:ok, response} -> params = Jason.decode!(params, keys: :atoms) params = Map.put(params, Shopifex.Shops.get_scope_field(), params[:scope]) From 7f89f07442aee2bd7b28c43fca69fd47e23047f0 Mon Sep 17 00:00:00 2001 From: NexPB Date: Tue, 1 Apr 2025 20:15:32 +0900 Subject: [PATCH 14/15] refactor: rename param to shop_url --- lib/shopifex_web/controllers/auth_controller.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/shopifex_web/controllers/auth_controller.ex b/lib/shopifex_web/controllers/auth_controller.ex index 475a269..aa024b0 100644 --- a/lib/shopifex_web/controllers/auth_controller.ex +++ b/lib/shopifex_web/controllers/auth_controller.ex @@ -304,8 +304,8 @@ defmodule ShopifexWeb.AuthController do end @spec embedded_app_uri(String.t()) :: URI.t() - def embedded_app_uri(host) do + def embedded_app_uri(shop_url) do api_key = Application.fetch_env!(:shopifex, :api_key) - URI.new!("https://#{host}/apps/#{api_key}") + URI.new!("https://#{shop_url}/apps/#{api_key}") end end From a44da25d5d56cd7125b4b4905c2471b1293109e2 Mon Sep 17 00:00:00 2001 From: NexPB Date: Tue, 1 Apr 2025 20:32:05 +0900 Subject: [PATCH 15/15] docs: readme referrence shopify cli --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8301285..67a9c7a 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,38 @@ def deps do ] end ``` + ## Quickstart + #### Run the install script + This will install all of the supported Shopifex features. -``` + +```sh mix shopifex.install ``` + Follow the output `config.ex` and `router.ex` instructions from the install script. + #### Run migrations -``` + +```sh mix ecto.migrate ``` + #### Update Shopify app details -Replace tunnel-url with your own where applicable. + +Replace tunnel-url with your own where applicable, or simply use follow the Shopify CLI [instructions](#using-shopifex-with-shopify-cli). + - Set "App URL" to `https://my-app.ngrok.io/auth` - Add `https://my-app.ngrok.io/auth/install` & `https://my-app.ngrok.io/auth/update` to your app's "Allowed redirection URL(s)" - Add your Shopify app's API key and API secret key to `config :shopifex, api_key: "your-api-key", secret: "your-api-secret"` ## Manual Installation + Create the shop schema where the installation data will be stored: -``` + +```sh mix phx.gen.schema Shop shops url:string access_token:string scope:string mix ecto.migrate ```