diff --git a/config/config.exs b/config/config.exs index e69d35f301..7cc6adde34 100644 --- a/config/config.exs +++ b/config/config.exs @@ -871,7 +871,8 @@ config :pleroma, ConcurrentLimiter, [ {Pleroma.Web.RichMedia.Helpers, [max_running: 5, max_waiting: 5]}, - {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]} + {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]}, + {Pleroma.Webhook.Notify, [max_running: 5, max_waiting: :infinity]} ] import_config "soapbox.exs" diff --git a/config/description.exs b/config/description.exs index cc5a908167..26483ff0e7 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3460,6 +3460,26 @@ suggestion: [5] } ] + }, + %{ + key: Pleroma.Webhook.Notify, + type: :keyword, + description: "Concurrent limits configuration for webhooks.", + suggestions: [max_running: 5, max_waiting: 5], + children: [ + %{ + key: :max_running, + type: :integer, + description: "Max running concurrently jobs.", + suggestion: [5] + }, + %{ + key: :max_waiting, + type: :integer, + description: "Max waiting jobs.", + suggestion: [5] + } + ] } ] } diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d808bc732b..e0be9f69c8 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -308,7 +308,11 @@ defp http_children(_, _), do: [] def limiters_setup do config = Config.get(ConcurrentLimiter, []) - [Pleroma.Web.RichMedia.Helpers, Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] + [ + Pleroma.Web.RichMedia.Helpers, + Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, + Pleroma.Webhook.Notify + ] |> Enum.each(fn module -> mod_config = Keyword.get(config, module, []) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d3263b8378..0f29ac8a59 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -8,6 +8,7 @@ defmodule Pleroma.User do import Ecto.Changeset import Ecto.Query import Ecto, only: [assoc: 2] + import Pleroma.Webhook.Notify, only: [trigger_webhooks: 2] alias Ecto.Multi alias Pleroma.Activity @@ -36,6 +37,7 @@ defmodule Pleroma.User do alias Pleroma.Web.Endpoint alias Pleroma.Web.OAuth alias Pleroma.Web.RelMe + alias Pleroma.Webhook alias Pleroma.Workers.BackgroundWorker require Logger @@ -860,6 +862,7 @@ defp autofollowing_users(user) do def register(%Ecto.Changeset{} = changeset) do with {:ok, user} <- Repo.insert(changeset) do post_register_action(user) + trigger_webhooks(user, :"account.created") end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 27581bf1fd..963818327b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger + alias Pleroma.Webhook alias Pleroma.Workers.BackgroundWorker alias Pleroma.Workers.PollWorker @@ -31,6 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do import Ecto.Query import Pleroma.Web.ActivityPub.Utils import Pleroma.Web.ActivityPub.Visibility + import Pleroma.Webhook.Notify, only: [trigger_webhooks: 2] require Logger require Pleroma.Constants @@ -391,6 +393,7 @@ defp do_flag( {:ok, activity} <- insert(flag_data, local), {:ok, stripped_activity} <- strip_report_status_data(activity), _ <- notify_and_stream(activity), + _ <- trigger_webhooks(activity, :"report.created"), :ok <- maybe_federate(stripped_activity) do User.all_superusers() diff --git a/lib/pleroma/web/admin_api/controllers/webhook_controller.ex b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex new file mode 100644 index 0000000000..054905cb4d --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.WebhookController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Webhook + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [:update, :create, :enable, :disable, :rotate_secret] + ) + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show]) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.WebhookOperation + + def index(conn, _) do + webhooks = + Webhook + |> Repo.all() + + render(conn, "index.json", webhooks: webhooks) + end + + def show(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id) do + render(conn, "show.json", webhook: webhook) + else + nil -> {:error, :not_found} + end + end + + def create(%{body_params: params} = conn, _) do + with {:ok, webhook} <- Webhook.create(params) do + render(conn, "show.json", webhook: webhook) + # else + # nil -> {:error, :not_found} + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + changeset <- Webhook.update(webhook, params), + {:ok, webhook} <- Repo.update(changeset) do + render(conn, "show.json", webhook: webhook) + end + end + + def delete(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.delete(webhook) do + render(conn, "show.json", webhook: webhook) + end + end + + def enable(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, true) do + render(conn, "show.json", webhook: webhook) + else + nil -> {:error, :not_found} + end + end + + def disable(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, false) do + render(conn, "show.json", webhook: webhook) + else + nil -> {:error, :not_found} + end + end + + def rotate_secret(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.rotate_secret(webhook) do + render(conn, "show.json", webhook: webhook) + else + nil -> {:error, :not_found} + end + end +end diff --git a/lib/pleroma/web/admin_api/views/webhook_view.ex b/lib/pleroma/web/admin_api/views/webhook_view.ex new file mode 100644 index 0000000000..725183029d --- /dev/null +++ b/lib/pleroma/web/admin_api/views/webhook_view.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.WebhookView do + use Pleroma.Web, :view + + alias Pleroma.Web.CommonAPI.Utils + + def render("index.json", %{webhooks: webhooks}) do + render_many(webhooks, __MODULE__, "show.json") + end + + def render("show.json", %{webhook: webhook}) do + %{ + id: webhook.id |> to_string(), + url: webhook.url, + events: webhook.events, + secret: webhook.secret, + enabled: webhook.enabled, + created_at: Utils.to_masto_date(webhook.inserted_at), + updated_at: Utils.to_masto_date(webhook.updated_at) + } + end + + def render("event.json", %{type: type, object: object}) do + %{ + type: type, + created_at: Utils.to_masto_date(NaiveDateTime.utc_now()), + object: object + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex b/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex new file mode 100644 index 0000000000..0c4e1797fb --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex @@ -0,0 +1,193 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.WebhookOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Retrieve a list of webhooks", + operationId: "AdminAPI.WebhookController.index", + security: [%{"oAuth" => ["admin:show"]}], + responses: %{ + 200 => + Operation.response("Array of webhooks", "application/json", %Schema{ + type: :array, + items: webhook() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Retrieve a webhook", + operationId: "AdminAPI.WebhookController.show", + security: [%{"oAuth" => ["admin:show"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Create a webhook", + operationId: "AdminAPI.WebhookController.create", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for creating a webhook", + type: :object, + properties: %{ + url: %Schema{type: :string, format: :uri, required: true}, + events: event_type(true), + enabled: %Schema{type: :boolean} + } + } + ), + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def update_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Update a webhook", + operationId: "AdminAPI.WebhookController.update", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for updating a webhook", + type: :object, + properties: %{ + url: %Schema{type: :string, format: :uri}, + events: event_type(), + enabled: %Schema{type: :boolean} + } + } + ), + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Delete a webhook", + operationId: "AdminAPI.WebhookController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def enable_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Enable a webhook", + operationId: "AdminAPI.WebhookController.enable", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def disable_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Disable a webhook", + operationId: "AdminAPI.WebhookController.disable", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def rotate_secret_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Rotate webhook signing secret", + operationId: "AdminAPI.WebhookController.rotate_secret", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + defp webhook do + %Schema{ + title: "Webhook", + description: "Schema for a webhook", + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + events: event_type(), + secret: %Schema{type: :string}, + enabled: %Schema{type: :boolean}, + created_at: %Schema{type: :string, format: :"date-time"}, + updated_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "id" => "1", + "url" => "https://example.com/webhook", + "events" => ["report.created"], + "secret" => "D3D8CF4BC11FD9C41FD34DCC38D282E451C8BD34", + "enabled" => true, + "created_at" => "2022-06-24T16:19:38.523Z", + "updated_at" => "2022-06-24T16:19:38.523Z" + } + } + end + + defp event_type(required \\ nil) do + %Schema{ + type: :array, + items: %Schema{ + title: "Event", + description: "Event type", + type: :string, + enum: ["account.created", "report.created"], + required: required + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Webhook ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index d7e7ae24e3..46b545d482 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.NotificationOperation do - alias OpenApiSpex.Operation alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index be8988a4a4..a9958fda11 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -238,6 +238,15 @@ defmodule Pleroma.Web.Router do post("/rules", RuleController, :create) patch("/rules/:id", RuleController, :update) delete("/rules/:id", RuleController, :delete) + + get("/webhooks", WebhookController, :index) + get("/webhooks/:id", WebhookController, :show) + post("/webhooks", WebhookController, :create) + patch("/webhooks/:id", WebhookController, :update) + delete("/webhooks/:id", WebhookController, :delete) + post("/webhooks/:id/enable", WebhookController, :enable) + post("/webhooks/:id/disable", WebhookController, :disable) + post("/webhooks/:id/rotate_secret", WebhookController, :rotate_secret) end # AdminAPI: admins and mods (staff) can perform these actions (if enabled by config) diff --git a/lib/pleroma/webhook.ex b/lib/pleroma/webhook.ex new file mode 100644 index 0000000000..d257f9d8cc --- /dev/null +++ b/lib/pleroma/webhook.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Webhook do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Repo + + @event_types [:"account.created", :"report.created"] + + schema "webhooks" do + field(:url, ObjectValidators.Uri, null: false) + field(:events, {:array, Ecto.Enum}, values: @event_types, null: false, default: []) + field(:secret, :string, null: false, default: "") + field(:enabled, :boolean, null: false, default: true) + + timestamps() + end + + def get(id), do: Repo.get(__MODULE__, id) + + def get_by_type(type) do + __MODULE__ + |> where([w], ^type in w.events) + |> Repo.all() + end + + def changeset(%__MODULE__{} = webhook, params) do + webhook + |> cast(params, [:url, :events, :enabled]) + |> validate_required([:url, :events]) + |> unique_constraint(:url) + |> strip_events() + |> put_secret() + end + + def update_changeset(%__MODULE__{} = webhook, params \\ %{}) do + webhook + |> cast(params, [:url, :events, :enabled]) + |> unique_constraint(:url) + |> strip_events() + end + + def create(params) do + {:ok, webhook} = + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + + webhook + end + + def update(%__MODULE__{} = webhook, params) do + {:ok, webhook} = + webhook + |> update_changeset(params) + |> Repo.update() + + webhook + end + + def delete(webhook), do: webhook |> Repo.delete() + + def rotate_secret(%__MODULE__{} = webhook) do + webhook + |> cast(%{}, []) + |> put_secret() + |> Repo.update() + end + + def set_enabled(%__MODULE__{} = webhook, enabled) do + webhook + |> cast(%{enabled: enabled}, [:enabled]) + |> Repo.update() + end + + defp strip_events(params) do + if Map.has_key?(params, :events) do + params + |> Map.put(:events, Enum.filter(params[:events], &Enum.member?(@event_types, &1))) + else + params + end + end + + defp put_secret(changeset) do + changeset + |> put_change(:secret, generate_secret()) + end + + defp generate_secret do + Base.encode16(:crypto.strong_rand_bytes(20)) + |> String.downcase() + end +end diff --git a/lib/pleroma/webhook/notify.ex b/lib/pleroma/webhook/notify.ex new file mode 100644 index 0000000000..d1b189ef0c --- /dev/null +++ b/lib/pleroma/webhook/notify.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Webhook.Notify do + alias Phoenix.View + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Webhook + + def trigger_webhooks(%Activity{} = activity, :"report.created" = type) do + webhooks = Webhook.get_by_type(type) + + Enum.each(webhooks, fn webhook -> + ConcurrentLimiter.limit(Webhook.Notify, fn -> + Task.start(fn -> report_created(webhook, activity) end) + end) + end) + end + + def trigger_webhooks(%User{} = user, :"account.created" = type) do + webhooks = Webhook.get_by_type(type) + + Enum.each(webhooks, fn webhook -> + ConcurrentLimiter.limit(Webhook.Notify, fn -> + Task.start(fn -> account_created(webhook, user) end) + end) + end) + end + + def report_created(%Webhook{} = webhook, %Activity{} = report) do + object = + View.render( + Pleroma.Web.MastodonAPI.Admin.ReportView, + "show.json", + Report.extract_report_info(report) + ) + + deliver(webhook, object, :"report.created") + end + + def account_created(%Webhook{} = webhook, %User{} = user) do + object = + View.render( + Pleroma.Web.MastodonAPI.Admin.AccountView, + "show.json", + user: user + ) + + deliver(webhook, object, :"account.created") + end + + defp deliver(%Webhook{url: url, secret: secret}, object, type) do + body = + View.render_to_string(Pleroma.Web.AdminAPI.WebhookView, "event.json", + type: type, + object: object + ) + + headers = [ + {"Content-Type", "application/json"}, + {"X-Hub-Signature", "sha256=#{signature(body, secret)}"} + ] + + Pleroma.HTTP.post(url, body, headers) + end + + defp signature(body, secret) do + :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16() + end +end diff --git a/priv/repo/migrations/20220624104914_create_webhooks.exs b/priv/repo/migrations/20220624104914_create_webhooks.exs new file mode 100644 index 0000000000..c7836fc0cb --- /dev/null +++ b/priv/repo/migrations/20220624104914_create_webhooks.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.CreateWebhooks do + use Ecto.Migration + + def change do + create_if_not_exists table(:webhooks) do + add(:url, :string, null: false) + add(:events, {:array, :string}, null: false, default: []) + add(:secret, :string, null: false, default: "") + add(:enabled, :boolean, null: false, default: true) + + timestamps() + end + + create_if_not_exists(unique_index(:webhooks, [:url])) + end +end diff --git a/test/pleroma/webhook_test.ex b/test/pleroma/webhook_test.ex new file mode 100644 index 0000000000..21763f1e00 --- /dev/null +++ b/test/pleroma/webhook_test.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.WebhookTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Repo + alias Pleroma.Webhook + + test "creating a webhook" do + %{id: id} = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]}) + + assert %{url: "https://example.com/webhook"} = Webhook.get(id) + end + + test "editing a webhook" do + %{id: id} = + webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]}) + + Webhook.update(webhook, %{events: [:"account.created"]}) + + assert %{events: [:"account.created"]} = Webhook.get(id) + end + + test "filter webhooks by type" do + %{id: id1} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + %{id: id2} = + Webhook.create(%{ + url: "https://example.com/webhook2", + events: [:"account.created", :"report.created"] + }) + + Webhook.create(%{url: "https://example.com/webhook3", events: [:"account.created"]}) + + assert [%{id: ^id1}, %{id: ^id2}] = Webhook.get_by_type(:"report.created") + end + + test "change webhook state" do + %{id: id, enabled: true} = + webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]}) + + Webhook.set_enabled(webhook, false) + assert %{enabled: false} = Webhook.get(id) + end + + test "rotate webhook secrets" do + %{id: id, secret: secret} = + webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]}) + + Webhook.rotate_secret(webhook) + %{secret: new_secret} = Webhook.get(id) + assert secret != new_secret + end +end