From a9c15ea6ae740ea7d1bab233cebe921ba0caac8f Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 31 Jan 2026 22:41:15 +0000 Subject: [PATCH] feat: add Printify webhook endpoint for real-time product updates - Add /webhooks/printify endpoint with HMAC-SHA256 signature verification - Add Webhooks context to handle product:updated, product:deleted events - Add ProductDeleteWorker for async product deletion - Add webhook API methods to Printify client (create, list, delete) - Add register_webhooks/2 to Printify provider - Add mix register_webhooks task for one-time webhook registration - Cache raw request body in endpoint for signature verification Usage: 1. Generate webhook secret: openssl rand -hex 20 2. Add to provider connection config as "webhook_secret" 3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify Co-Authored-By: Claude Opus 4.5 --- lib/mix/tasks/register_webhooks.ex | 69 ++++++++++++ lib/simpleshop_theme/clients/printify.ex | 35 ++++++ lib/simpleshop_theme/providers/printify.ex | 60 +++++++++++ lib/simpleshop_theme/webhooks.ex | 45 ++++++++ .../webhooks/product_delete_worker.ex | 41 +++++++ .../controllers/webhook_controller.ex | 36 +++++++ lib/simpleshop_theme_web/endpoint.ex | 1 + .../plugs/cache_raw_body.ex | 22 ++++ .../plugs/verify_printify_webhook.ex | 63 +++++++++++ lib/simpleshop_theme_web/router.ex | 14 ++- .../webhooks/product_delete_worker_test.exs | 48 +++++++++ test/simpleshop_theme/webhooks_test.exs | 65 +++++++++++ .../controllers/webhook_controller_test.exs | 101 ++++++++++++++++++ 13 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 lib/mix/tasks/register_webhooks.ex create mode 100644 lib/simpleshop_theme/webhooks.ex create mode 100644 lib/simpleshop_theme/webhooks/product_delete_worker.ex create mode 100644 lib/simpleshop_theme_web/controllers/webhook_controller.ex create mode 100644 lib/simpleshop_theme_web/plugs/cache_raw_body.ex create mode 100644 lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex create mode 100644 test/simpleshop_theme/webhooks/product_delete_worker_test.exs create mode 100644 test/simpleshop_theme/webhooks_test.exs create mode 100644 test/simpleshop_theme_web/controllers/webhook_controller_test.exs diff --git a/lib/mix/tasks/register_webhooks.ex b/lib/mix/tasks/register_webhooks.ex new file mode 100644 index 0000000..e218d94 --- /dev/null +++ b/lib/mix/tasks/register_webhooks.ex @@ -0,0 +1,69 @@ +defmodule Mix.Tasks.RegisterWebhooks do + @moduledoc """ + Registers Printify webhooks for a shop. + + Usage: + mix register_webhooks https://yourshop.com/webhooks/printify + + Prerequisites: + - Printify provider connection must exist with shop_id configured + - webhook_secret must be set in the connection config + + Generate a webhook secret with: + openssl rand -hex 20 + """ + + use Mix.Task + + @shortdoc "Register Printify webhooks for product updates" + + @impl Mix.Task + def run([url]) do + Mix.Task.run("app.start") + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Providers.Printify + + case Products.get_provider_connection_by_type("printify") do + nil -> + Mix.shell().error("No Printify connection found. Create one at /admin/providers first.") + + conn -> + Mix.shell().info("Registering webhooks for URL: #{url}") + + case Printify.register_webhooks(conn, url) do + {:ok, results} -> + Enum.each(results, fn + {:ok, event, response} -> + Mix.shell().info("✓ Registered: #{event} (ID: #{response["id"]})") + + {:error, event, reason} -> + Mix.shell().error("✗ Failed: #{event} - #{inspect(reason)}") + end) + + Mix.shell().info("\nDone!") + + {:error, :no_webhook_secret} -> + Mix.shell().error(""" + No webhook secret configured. + + Generate one with: openssl rand -hex 20 + + Then add it to your provider connection config as "webhook_secret". + """) + + {:error, reason} -> + Mix.shell().error("Failed: #{inspect(reason)}") + end + end + end + + def run(_) do + Mix.shell().error(""" + Usage: mix register_webhooks + + Example: + mix register_webhooks https://yourshop.com/webhooks/printify + """) + end +end diff --git a/lib/simpleshop_theme/clients/printify.ex b/lib/simpleshop_theme/clients/printify.ex index 9360722..e483c22 100644 --- a/lib/simpleshop_theme/clients/printify.ex +++ b/lib/simpleshop_theme/clients/printify.ex @@ -183,6 +183,41 @@ defmodule SimpleshopTheme.Clients.Printify do get("/shops/#{shop_id}/orders/#{order_id}.json") end + # ============================================================================= + # Webhooks + # ============================================================================= + + @doc """ + Register a webhook with Printify. + + ## Event types + - "product:publish:started" + - "product:updated" + - "product:deleted" + - "shop:disconnected" + """ + def create_webhook(shop_id, url, topic, secret) do + post("/shops/#{shop_id}/webhooks.json", %{ + topic: topic, + url: url, + secret: secret + }) + end + + @doc """ + List registered webhooks for a shop. + """ + def list_webhooks(shop_id) do + get("/shops/#{shop_id}/webhooks.json") + end + + @doc """ + Delete a webhook. + """ + def delete_webhook(shop_id, webhook_id) do + delete("/shops/#{shop_id}/webhooks/#{webhook_id}.json") + end + @doc """ Download a file from a URL to a local path. """ diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex index 844b03f..ec06762 100644 --- a/lib/simpleshop_theme/providers/printify.ex +++ b/lib/simpleshop_theme/providers/printify.ex @@ -114,6 +114,66 @@ defmodule SimpleshopTheme.Providers.Printify do end end + # ============================================================================= + # Webhook Registration + # ============================================================================= + + @webhook_events ["product:updated", "product:deleted", "product:publish:started"] + + @doc """ + Registers webhooks for product events with Printify. + + Returns {:ok, results} or {:error, reason}. + """ + def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do + shop_id = config["shop_id"] + secret = config["webhook_secret"] + + cond do + is_nil(shop_id) -> + {:error, :no_shop_id} + + is_nil(secret) or secret == "" -> + {:error, :no_webhook_secret} + + true -> + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key) do + results = + Enum.map(@webhook_events, fn event -> + case Client.create_webhook(shop_id, webhook_url, event, secret) do + {:ok, response} -> {:ok, event, response} + {:error, reason} -> {:error, event, reason} + end + end) + + {:ok, results} + else + nil -> {:error, :no_api_key} + end + end + end + + @doc """ + Lists registered webhooks for the shop. + """ + def list_webhooks(%ProviderConnection{config: config} = conn) do + shop_id = config["shop_id"] + + if is_nil(shop_id) do + {:error, :no_shop_id} + else + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key), + {:ok, webhooks} <- Client.list_webhooks(shop_id) do + {:ok, webhooks} + else + nil -> {:error, :no_api_key} + {:error, _} = error -> error + end + end + end + # ============================================================================= # Data Normalization # ============================================================================= diff --git a/lib/simpleshop_theme/webhooks.ex b/lib/simpleshop_theme/webhooks.ex new file mode 100644 index 0000000..982c1b0 --- /dev/null +++ b/lib/simpleshop_theme/webhooks.ex @@ -0,0 +1,45 @@ +defmodule SimpleshopTheme.Webhooks do + @moduledoc """ + Handles incoming webhook events from POD providers. + """ + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Sync.ProductSyncWorker + alias SimpleshopTheme.Webhooks.ProductDeleteWorker + + require Logger + + @doc """ + Handles a Printify webhook event. + + Returns :ok or {:ok, job} on success, {:error, reason} on failure. + """ + def handle_printify_event("product:updated", %{"id" => _product_id}) do + enqueue_product_sync() + end + + def handle_printify_event("product:publish:started", %{"id" => _product_id}) do + enqueue_product_sync() + end + + def handle_printify_event("product:deleted", %{"id" => product_id}) do + ProductDeleteWorker.enqueue(product_id) + end + + def handle_printify_event("shop:disconnected", _resource) do + Logger.warning("Printify shop disconnected - manual intervention needed") + :ok + end + + def handle_printify_event(event_type, _resource) do + Logger.info("Ignoring unhandled Printify event: #{event_type}") + :ok + end + + defp enqueue_product_sync do + case Products.get_provider_connection_by_type("printify") do + nil -> {:error, :no_connection} + conn -> ProductSyncWorker.enqueue(conn.id) + end + end +end diff --git a/lib/simpleshop_theme/webhooks/product_delete_worker.ex b/lib/simpleshop_theme/webhooks/product_delete_worker.ex new file mode 100644 index 0000000..d5f9b05 --- /dev/null +++ b/lib/simpleshop_theme/webhooks/product_delete_worker.ex @@ -0,0 +1,41 @@ +defmodule SimpleshopTheme.Webhooks.ProductDeleteWorker do + @moduledoc """ + Oban worker for deleting products removed from POD providers. + """ + + use Oban.Worker, queue: :sync, max_attempts: 3 + + alias SimpleshopTheme.Products + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"provider_product_id" => provider_product_id}}) do + case Products.get_provider_connection_by_type("printify") do + nil -> + Logger.warning("No Printify connection found for product deletion") + {:cancel, :no_connection} + + conn -> + case Products.get_product_by_provider(conn.id, provider_product_id) do + nil -> + Logger.info("Product #{provider_product_id} already deleted or not found") + :ok + + product -> + Logger.info("Deleting product #{product.id} (provider: #{provider_product_id})") + + case Products.delete_product(product) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + end + + def enqueue(provider_product_id) do + %{provider_product_id: to_string(provider_product_id)} + |> new() + |> Oban.insert() + end +end diff --git a/lib/simpleshop_theme_web/controllers/webhook_controller.ex b/lib/simpleshop_theme_web/controllers/webhook_controller.ex new file mode 100644 index 0000000..f2c2f1e --- /dev/null +++ b/lib/simpleshop_theme_web/controllers/webhook_controller.ex @@ -0,0 +1,36 @@ +defmodule SimpleshopThemeWeb.WebhookController do + use SimpleshopThemeWeb, :controller + + alias SimpleshopTheme.Webhooks + + require Logger + + @doc """ + Receives Printify webhook events. + + Events: + - product:publish:started - Product publish initiated + - product:updated - Product was modified + - product:deleted - Product was deleted + - shop:disconnected - Shop was disconnected + """ + def printify(conn, params) do + event_type = params["type"] || params["event"] + resource = params["resource"] || params["data"] || %{} + + Logger.info("Received Printify webhook: #{event_type}") + + case Webhooks.handle_printify_event(event_type, resource) do + :ok -> + json(conn, %{status: "ok"}) + + {:ok, _} -> + json(conn, %{status: "ok"}) + + {:error, reason} -> + Logger.warning("Webhook handling failed: #{inspect(reason)}") + # Still return 200 to prevent Printify retrying + json(conn, %{status: "ok"}) + end + end +end diff --git a/lib/simpleshop_theme_web/endpoint.ex b/lib/simpleshop_theme_web/endpoint.ex index 86853fc..cff1ba9 100644 --- a/lib/simpleshop_theme_web/endpoint.ex +++ b/lib/simpleshop_theme_web/endpoint.ex @@ -54,6 +54,7 @@ defmodule SimpleshopThemeWeb.Endpoint do plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], + body_reader: {SimpleshopThemeWeb.Plugs.CacheRawBody, :read_body, []}, json_decoder: Phoenix.json_library() plug Plug.MethodOverride diff --git a/lib/simpleshop_theme_web/plugs/cache_raw_body.ex b/lib/simpleshop_theme_web/plugs/cache_raw_body.ex new file mode 100644 index 0000000..f1a3ca7 --- /dev/null +++ b/lib/simpleshop_theme_web/plugs/cache_raw_body.ex @@ -0,0 +1,22 @@ +defmodule SimpleshopThemeWeb.Plugs.CacheRawBody do + @moduledoc """ + Custom body reader that caches the raw request body for webhook signature verification. + Used with Plug.Parsers :body_reader option. + """ + + def read_body(conn, opts) do + case Plug.Conn.read_body(conn, opts) do + {:ok, body, conn} -> + conn = Plug.Conn.assign(conn, :raw_body, body) + {:ok, body, conn} + + {:more, body, conn} -> + existing = conn.assigns[:raw_body] || "" + conn = Plug.Conn.assign(conn, :raw_body, existing <> body) + {:more, body, conn} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex b/lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex new file mode 100644 index 0000000..49a8637 --- /dev/null +++ b/lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex @@ -0,0 +1,63 @@ +defmodule SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook do + @moduledoc """ + Verifies Printify webhook signatures using HMAC-SHA256. + + Expects: + - Raw body cached in conn.assigns[:raw_body] + - X-Pfy-Signature header in format "sha256={hex_digest}" + - Webhook secret stored in provider connection config + """ + + import Plug.Conn + require Logger + + alias SimpleshopTheme.Products + + def init(opts), do: opts + + def call(conn, _opts) do + with {:ok, signature} <- get_signature(conn), + {:ok, secret} <- get_webhook_secret(), + :ok <- verify_signature(conn.assigns[:raw_body], secret, signature) do + conn + else + {:error, reason} -> + Logger.warning("Printify webhook verification failed: #{reason}") + + conn + |> put_resp_content_type("application/json") + |> send_resp(401, Jason.encode!(%{error: "Invalid signature"})) + |> halt() + end + end + + defp get_signature(conn) do + case get_req_header(conn, "x-pfy-signature") do + ["sha256=" <> hex_digest] -> {:ok, hex_digest} + [_other] -> {:error, :invalid_signature_format} + [] -> {:error, :missing_signature} + end + end + + defp get_webhook_secret do + case Products.get_provider_connection_by_type("printify") do + %{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" -> + {:ok, secret} + + _ -> + {:error, :no_webhook_secret} + end + end + + defp verify_signature(body, secret, expected_hex) do + computed = + :crypto.mac(:hmac, :sha256, secret, body || "") + |> Base.encode16(case: :lower) + + if Plug.Crypto.secure_compare(computed, String.downcase(expected_hex)) do + :ok + else + {:error, :signature_mismatch} + end + end +end diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index d0f584b..f955d38 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -17,6 +17,10 @@ defmodule SimpleshopThemeWeb.Router do plug :accepts, ["json"] end + pipeline :printify_webhook do + plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook + end + pipeline :shop do plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root} plug SimpleshopThemeWeb.Plugs.LoadTheme @@ -46,10 +50,12 @@ defmodule SimpleshopThemeWeb.Router do get "/:id/recolored/:color", ImageController, :recolored_svg end - # Other scopes may use custom stacks. - # scope "/api", SimpleshopThemeWeb do - # pipe_through :api - # end + # Webhook endpoints (no CSRF, signature verified) + scope "/webhooks", SimpleshopThemeWeb do + pipe_through [:api, :printify_webhook] + + post "/printify", WebhookController, :printify + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:simpleshop_theme, :dev_routes) do diff --git a/test/simpleshop_theme/webhooks/product_delete_worker_test.exs b/test/simpleshop_theme/webhooks/product_delete_worker_test.exs new file mode 100644 index 0000000..87190a3 --- /dev/null +++ b/test/simpleshop_theme/webhooks/product_delete_worker_test.exs @@ -0,0 +1,48 @@ +defmodule SimpleshopTheme.Webhooks.ProductDeleteWorkerTest do + use SimpleshopTheme.DataCase + use Oban.Testing, repo: SimpleshopTheme.Repo + + alias SimpleshopTheme.Webhooks.ProductDeleteWorker + alias SimpleshopTheme.Products + + import SimpleshopTheme.ProductsFixtures + + describe "perform/1" do + test "deletes product when found" do + conn = provider_connection_fixture(%{provider_type: "printify"}) + + {:ok, product, :created} = + Products.upsert_product(conn, %{ + provider_product_id: "test-product-123", + title: "Test Product", + provider_data: %{} + }) + + assert Products.get_product(product.id) != nil + + assert :ok = + perform_job(ProductDeleteWorker, %{ + provider_product_id: "test-product-123" + }) + + assert Products.get_product(product.id) == nil + end + + test "returns ok when product not found" do + _conn = provider_connection_fixture(%{provider_type: "printify"}) + + assert :ok = + perform_job(ProductDeleteWorker, %{ + provider_product_id: "nonexistent-product" + }) + end + + test "cancels when no provider connection" do + # No connection created + assert {:cancel, :no_connection} = + perform_job(ProductDeleteWorker, %{ + provider_product_id: "test-product-123" + }) + end + end +end diff --git a/test/simpleshop_theme/webhooks_test.exs b/test/simpleshop_theme/webhooks_test.exs new file mode 100644 index 0000000..2c0ee55 --- /dev/null +++ b/test/simpleshop_theme/webhooks_test.exs @@ -0,0 +1,65 @@ +defmodule SimpleshopTheme.WebhooksTest do + use SimpleshopTheme.DataCase + + alias SimpleshopTheme.Webhooks + + import SimpleshopTheme.ProductsFixtures + + setup do + conn = provider_connection_fixture(%{provider_type: "printify"}) + {:ok, provider_connection: conn} + end + + describe "handle_printify_event/2" do + test "product:updated triggers sync", %{provider_connection: _conn} do + # With inline Oban, the job executes immediately (and fails due to no real API key) + # But the handler should still return {:ok, _} after inserting the job + result = + Webhooks.handle_printify_event( + "product:updated", + %{"id" => "123", "shop_id" => "456"} + ) + + assert {:ok, %Oban.Job{}} = result + end + + test "product:publish:started triggers sync", %{provider_connection: _conn} do + result = + Webhooks.handle_printify_event( + "product:publish:started", + %{"id" => "123"} + ) + + assert {:ok, %Oban.Job{}} = result + end + + test "product:deleted triggers delete", %{provider_connection: _conn} do + result = + Webhooks.handle_printify_event( + "product:deleted", + %{"id" => "123"} + ) + + assert {:ok, %Oban.Job{}} = result + end + + test "shop:disconnected returns ok" do + assert :ok = Webhooks.handle_printify_event("shop:disconnected", %{}) + end + + test "unknown event returns ok" do + assert :ok = Webhooks.handle_printify_event("unknown:event", %{}) + end + + test "returns error when no provider connection" do + # Delete all connections first + SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection) + + assert {:error, :no_connection} = + Webhooks.handle_printify_event( + "product:updated", + %{"id" => "123"} + ) + end + end +end diff --git a/test/simpleshop_theme_web/controllers/webhook_controller_test.exs b/test/simpleshop_theme_web/controllers/webhook_controller_test.exs new file mode 100644 index 0000000..30c1a2a --- /dev/null +++ b/test/simpleshop_theme_web/controllers/webhook_controller_test.exs @@ -0,0 +1,101 @@ +defmodule SimpleshopThemeWeb.WebhookControllerTest do + use SimpleshopThemeWeb.ConnCase + + import SimpleshopTheme.ProductsFixtures + + @webhook_secret "test_webhook_secret_123" + + setup do + _conn = + provider_connection_fixture(%{ + provider_type: "printify", + config: %{"shop_id" => "12345", "webhook_secret" => @webhook_secret} + }) + + :ok + end + + describe "POST /webhooks/printify" do + test "returns 401 without signature", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/webhooks/printify", %{type: "product:updated"}) + + assert json_response(conn, 401)["error"] == "Invalid signature" + end + + test "returns 401 with invalid signature", %{conn: conn} do + body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}}) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pfy-signature", "sha256=invalid") + |> post(~p"/webhooks/printify", body) + + assert json_response(conn, 401)["error"] == "Invalid signature" + end + + test "accepts valid signature and returns 200", %{conn: conn} do + body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}}) + signature = compute_signature(body, @webhook_secret) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pfy-signature", "sha256=#{signature}") + |> post(~p"/webhooks/printify", body) + + assert json_response(conn, 200)["status"] == "ok" + end + + test "handles product:updated event", %{conn: conn} do + body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}}) + signature = compute_signature(body, @webhook_secret) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pfy-signature", "sha256=#{signature}") + |> post(~p"/webhooks/printify", body) + + # Should return 200 even if job processing fails (inline mode tries to execute) + assert json_response(conn, 200)["status"] == "ok" + end + + test "handles product:deleted event", %{conn: conn} do + body = Jason.encode!(%{type: "product:deleted", resource: %{id: "456"}}) + signature = compute_signature(body, @webhook_secret) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pfy-signature", "sha256=#{signature}") + |> post(~p"/webhooks/printify", body) + + assert json_response(conn, 200)["status"] == "ok" + end + + test "returns 401 when no webhook secret configured", %{conn: conn} do + # Remove the provider connection to simulate no secret + SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection) + + body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}}) + signature = compute_signature(body, @webhook_secret) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pfy-signature", "sha256=#{signature}") + |> post(~p"/webhooks/printify", body) + + assert json_response(conn, 401)["error"] == "Invalid signature" + end + end + + defp compute_signature(body, secret) do + :crypto.mac(:hmac, :sha256, secret, body) + |> Base.encode16(case: :lower) + end +end