diff --git a/changelog.d/webhooks.add b/changelog.d/webhooks.add new file mode 100644 index 000000000..323428f9f --- /dev/null +++ b/changelog.d/webhooks.add @@ -0,0 +1 @@ +Add support for Mastodon-compatible webhooks \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index 8a7f25916..d4a70450f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -932,7 +932,8 @@ config :pleroma, Pleroma.User.Backup, timeout: :timer.minutes(30) config :pleroma, ConcurrentLimiter, [ - {Pleroma.Search, [max_running: 30, max_waiting: 50]} + {Pleroma.Search, [max_running: 30, max_waiting: 50]}, + {Pleroma.Webhook.Notify, [max_running: 5, max_waiting: 200]} ] config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fetch: true diff --git a/config/description.exs b/config/description.exs index c07dc5074..f435628aa 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3523,6 +3523,26 @@ config :pleroma, :config_description, [ suggestion: [5] } ] + }, + %{ + key: Pleroma.Webhook.Notify, + type: :keyword, + description: "Concurrent limits configuration for webhooks.", + suggestions: [max_running: 5, max_waiting: 200], + children: [ + %{ + key: :max_running, + type: :integer, + description: "Max running concurrently jobs.", + suggestion: [5] + }, + %{ + key: :max_waiting, + type: :integer, + description: "Max waiting jobs.", + suggestion: [200] + } + ] } ] }, diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index 3719ceeb9..ec58e8c19 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -1839,3 +1839,92 @@ Note that this differs from the Mastodon API variant: Mastodon API only returns ```json {} ``` + +## `GET /api/v1/pleroma/admin/webhooks` + +### List webhooks + +- Method: `GET` +- Response: + +```json +[ + { + "enabled": true, + "id": "2", + "events": ["account.created"], + "url": "https://webhook.example/", + "secret": "eb85d4ccd8510e78f912743949dc354e8146987d", + "updated_at": "2022-10-29T17:44:16.000Z", + "created_at": "2022-10-29T17:44:13.000Z" + } +] +``` + +## `GET /api/v1/pleroma/admin/webhooks/:id` + +### Get an individual webhook + +- Method: `GET` +- Params: + - `id`: **string** Webhook ID +- Response: A webhook + +## `POST /api/v1/pleroma/admin/webhooks` + +### Create a webhook + +- Method: `POST` +- Params: + - `url`: **string** Webhook URL + - `events`: **[string]** Types of events to trigger on (`account.created`, `report.created`) + - *optional* `enabled`: **boolean** Whether webhook is enabled +- Response: A webhook + +## `PATCH /api/v1/pleroma/admin/webhooks/:id` + +### Update a webhook + +- Method: `PATCH` +- Params: + - `id`: **string** Webhook ID + - *optional* `url`: **string** Webhook URL + - *optional* `events`: **[string]** Types of events to trigger on (`account.created`, `report.created`) + - *optional* `enabled`: **boolean** Whether webhook is enabled +- Response: A webhook + +## `DELETE /api/v1/pleroma/admin/webhooks/:id` + +### Delete a webhook + +- Method: `DELETE` +- Params: + - `id`: **string** Webhook ID +- Response: A webhook + +## `POST /api/v1/pleroma/admin/webhooks/:id/enable` + +### Activate a webhook + +- Method: `POST` +- Params: + - `id`: **string** Webhook ID +- Response: A webhook + +## `POST /api/v1/pleroma/admin/webhooks/:id/disable` + +### Deactivate a webhook + +- Method: `POST` +- Params: + - `id`: **string** Webhook ID +- Response: A webhook + +## `POST /api/v1/pleroma/admin/webhooks/:id/rotate_secret` + +### Rotate webhook signing secret + +- Method: `POST` +- Params: + - `id`: **string** Webhook ID +- Response: A webhook diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c7f6b5702..353e91f72 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -330,7 +330,8 @@ defmodule Pleroma.Application do config = Config.get(ConcurrentLimiter, []) [ - Pleroma.Search + Pleroma.Search, + 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 6678b049a..56c4565d9 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -39,6 +39,7 @@ defmodule Pleroma.User do alias Pleroma.Web.Endpoint alias Pleroma.Web.OAuth alias Pleroma.Web.RelMe + alias Pleroma.Webhook.Notify alias Pleroma.Workers.BackgroundWorker alias Pleroma.Workers.DeleteWorker alias Pleroma.Workers.UserRefreshWorker @@ -987,6 +988,7 @@ defmodule Pleroma.User do @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" def register(%Ecto.Changeset{} = changeset) do with {:ok, user} <- Repo.insert(changeset) do + Notify.trigger_webhooks(user, :"account.created") post_register_action(user) end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index cdad6a49b..93f647e2a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -30,6 +30,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 @@ -415,7 +416,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do with flag_data <- make_flag_data(params, additional), {:ok, activity} <- insert(flag_data, local), _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do + _ <- trigger_webhooks(activity, :"report.created"), + :ok <- + maybe_federate(activity) do User.all_users_with_privilege(:reports_manage_reports) |> Enum.filter(fn user -> user.ap_id != actor end) |> Enum.filter(fn user -> not is_nil(user.email) end) diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex index e72f45c21..d8fb54cad 100644 --- a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -11,6 +11,12 @@ defmodule Pleroma.Web.AdminAPI.FallbackController do |> json(%{error: dgettext("errors", "Not found")}) end + def call(conn, {:error, :forbidden}) do + conn + |> put_status(:forbidden) + |> json(%{error: dgettext("errors", "Forbidden")}) + end + def call(conn, {:error, reason}) do conn |> put_status(:bad_request) 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 000000000..4d064e0b5 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex @@ -0,0 +1,101 @@ +# 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, :delete, :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(webhook_params(params)) do + render(conn, "show.json", webhook: webhook) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.update(webhook, webhook_params(params)) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def delete(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.delete(webhook) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def enable(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, true) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def disable(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, false) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def rotate_secret(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.rotate_secret(webhook) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + defp webhook_params(params) do + Map.drop(params, [:internal, "internal"]) + 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 000000000..0b839090b --- /dev/null +++ b/lib/pleroma/web/admin_api/views/webhook_view.ex @@ -0,0 +1,34 @@ +# 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, + internal: webhook.internal, + 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 + %{ + event: type, + created_at: Utils.to_masto_date(NaiveDateTime.utc_now()), + object: object + } + end +end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index ff6363603..e6741b74b 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -105,7 +105,8 @@ defmodule Pleroma.Web.ApiSpec do "Report management", "Status administration", "User administration", - "Announcement management" + "Announcement management", + "Webhooks" ] }, %{ 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 000000000..f444857f0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex @@ -0,0 +1,208 @@ +# 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 + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + 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:read"]}], + 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:read"]}], + 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, + required: [:url, :events], + properties: %{ + url: %Schema{type: :string, format: :uri, maxLength: 255}, + events: event_type(min_items: 1), + enabled: %Schema{type: :boolean} + }, + additionalProperties: false + } + ), + 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, maxLength: 255}, + events: event_type(min_items: 1), + enabled: %Schema{type: :boolean} + }, + additionalProperties: false + } + ), + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + 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()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + 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()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + 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()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + 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()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + 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, maxLength: 255}, + events: event_type(), + secret: %Schema{type: :string}, + enabled: %Schema{type: :boolean}, + internal: %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, + "internal" => false, + "created_at" => "2022-06-24T16:19:38.523Z", + "updated_at" => "2022-06-24T16:19:38.523Z" + } + } + end + + defp event_type(opts \\ []) do + schema = %Schema{ + type: :array, + items: %Schema{ + title: "Event", + description: "Event type", + type: :string, + enum: ["account.created", "report.created"] + } + } + + case Keyword.fetch(opts, :min_items) do + {:ok, min_items} -> %{schema | minItems: min_items} + :error -> schema + end + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Webhook ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 27229ed41..8cae63ae6 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -322,6 +322,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 privileged by role) diff --git a/lib/pleroma/webhook.ex b/lib/pleroma/webhook.ex new file mode 100644 index 000000000..ea8903b40 --- /dev/null +++ b/lib/pleroma/webhook.ex @@ -0,0 +1,98 @@ +# 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) + field(:events, {:array, Ecto.Enum}, values: @event_types, default: []) + field(:secret, :string, default: "") + field(:enabled, :boolean, default: true) + field(:internal, :boolean, default: false) + + timestamps() + end + + def get(id), do: Repo.get(__MODULE__, id) + + def get_by_type(type) do + __MODULE__ + |> where([w], ^type in w.events) + |> where([w], w.enabled == true) + |> Repo.all() + end + + def changeset(%__MODULE__{} = webhook, params) do + webhook + |> cast(params, [:url, :events, :enabled, :internal]) + |> validate_required([:url, :events]) + |> validate_length(:url, max: 255) + |> validate_events_present() + |> unique_constraint(:url) + |> put_secret() + end + + def update_changeset(%__MODULE__{} = webhook, params \\ %{}) do + webhook + |> cast(params, [:url, :events, :enabled, :internal]) + |> validate_length(:url, max: 255) + |> validate_events_present() + |> unique_constraint(:url) + end + + def create(params) do + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + end + + def update(%__MODULE__{} = webhook, params) do + webhook + |> update_changeset(params) + |> Repo.update() + 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 validate_events_present(changeset) do + cond do + Keyword.has_key?(changeset.errors, :events) -> changeset + get_field(changeset, :internal) -> changeset + match?([_ | _], get_field(changeset, :events)) -> changeset + true -> add_error(changeset, :events, "can't be blank") + 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 000000000..df5d9e27a --- /dev/null +++ b/lib/pleroma/webhook/notify.ex @@ -0,0 +1,94 @@ +# 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 + alias Pleroma.Workers.WebhookWorker + + def trigger_webhooks(%Activity{} = activity, :"report.created" = type) do + webhooks = Webhook.get_by_type(type) + + webhooks + |> Enum.map(fn webhook -> + WebhookWorker.new(%{ + "op" => "notify", + "type" => type, + "webhook_id" => webhook.id, + "activity_id" => activity.id + }) + end) + |> Oban.insert_all() + end + + def trigger_webhooks(%User{} = user, :"account.created" = type) do + webhooks = Webhook.get_by_type(type) + + webhooks + |> Enum.map(fn webhook -> + WebhookWorker.new(%{ + "op" => "notify", + "type" => type, + "webhook_id" => webhook.id, + "user_id" => user.id + }) + end) + |> Oban.insert_all() + 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)}"} + ] + + ConcurrentLimiter.limit(__MODULE__, fn -> + case Pleroma.HTTP.post(url, body, headers) do + {:ok, %Tesla.Env{status: status}} when status in 200..299 -> + :ok + + {:ok, %Tesla.Env{status: status}} -> + {:error, {:http_status, status}} + + {:error, _reason} = error -> + error + end + end) + end + + defp signature(body, secret) do + :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower) + end +end diff --git a/lib/pleroma/workers/webhook_worker.ex b/lib/pleroma/workers/webhook_worker.ex new file mode 100644 index 000000000..d39265fbf --- /dev/null +++ b/lib/pleroma/workers/webhook_worker.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.WebhookWorker do + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Webhook + alias Pleroma.Webhook.Notify + + use Oban.Worker, queue: :background + + @impl true + def perform(%Job{ + args: %{ + "op" => "notify", + "type" => "report.created", + "webhook_id" => webhook_id, + "activity_id" => report_id + } + }) do + with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id), + %Activity{} = report <- Activity.get_by_id(report_id) do + Notify.report_created(webhook, report) + else + %Webhook{enabled: false} -> {:cancel, :disabled} + nil -> {:cancel, :not_found} + end + end + + def perform(%Job{ + args: %{ + "op" => "notify", + "type" => "account.created", + "webhook_id" => webhook_id, + "user_id" => user_id + } + }) do + with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id), + %User{} = user <- User.get_by_id(user_id) do + Notify.account_created(webhook, user) + else + %Webhook{enabled: false} -> {:cancel, :disabled} + nil -> {:cancel, :not_found} + end + end + + @impl true + def timeout(_job), do: :timer.seconds(10) +end diff --git a/priv/repo/migrations/20220624104914_create_webhooks.exs b/priv/repo/migrations/20220624104914_create_webhooks.exs new file mode 100644 index 000000000..c7836fc0c --- /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/priv/repo/migrations/20221029171353_add_internal_to_webhooks.exs b/priv/repo/migrations/20221029171353_add_internal_to_webhooks.exs new file mode 100644 index 000000000..8c8ce7fc7 --- /dev/null +++ b/priv/repo/migrations/20221029171353_add_internal_to_webhooks.exs @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.AddInternalToWebhooks do + use Ecto.Migration + + def change do + alter table(:webhooks) do + add(:internal, :boolean, default: false, null: false) + end + end +end diff --git a/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs index 5b567325c..0f38891b4 100644 --- a/test/mix/tasks/pleroma/database_test.exs +++ b/test/mix/tasks/pleroma/database_test.exs @@ -417,7 +417,8 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do ["user_invite_tokens"], ["user_notes"], ["user_relationships"], - ["users"] + ["users"], + ["webhooks"] ] end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 9ae9cce26..63c0c2bb8 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -11,12 +11,14 @@ defmodule Pleroma.UserTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI + alias Pleroma.Webhook.Notify use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo import Pleroma.Factory import ExUnit.CaptureLog + import Mock import Swoosh.TestAssertions setup do @@ -719,6 +721,14 @@ defmodule Pleroma.UserTest do assert user.is_confirmed end + + test_with_mock "triggers webhooks", Notify, trigger_webhooks: fn _, _ -> nil end do + cng = User.register_changeset(%User{}, @full_user_data) + + {:ok, registered_user} = User.register(cng) + + assert_called(Notify.trigger_webhooks(registered_user, :"account.created")) + end end describe "user registration, with :account_activation_required" do diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index dd7a82a55..ed6db558b 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI + alias Pleroma.Webhook.Notify import ExUnit.CaptureLog import Mock @@ -1877,6 +1878,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert Repo.aggregate(Object, :count, :id) == 1 assert Repo.aggregate(Notification, :count, :id) == 0 end + + test_with_mock "triggers webhooks", + %{ + reporter: reporter, + context: context, + target_account: target_account, + reported_activity: reported_activity, + content: content + }, + Notify, + [:passthrough], + trigger_webhooks: fn _, _ -> nil end do + {:ok, activity} = + ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [reported_activity], + content: content + }) + + assert_called(Notify.trigger_webhooks(activity, :"report.created")) + end end test "fetch_activities/2 returns activities addressed to a list " do diff --git a/test/pleroma/web/admin_api/controllers/webhook_controller_test.exs b/test/pleroma/web/admin_api/controllers/webhook_controller_test.exs new file mode 100644 index 000000000..800d6367e --- /dev/null +++ b/test/pleroma/web/admin_api/controllers/webhook_controller_test.exs @@ -0,0 +1,156 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.WebhookControllerTest do + use Pleroma.Web.ConnCase, async: false + + import Pleroma.Factory + + alias Pleroma.Webhook + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/webhook" do + test "lists existing webhooks", %{conn: conn} do + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + Webhook.create(%{url: "https://example.com/webhook2", events: [:"account.created"]}) + + response = + conn + |> get("/api/pleroma/admin/webhooks") + |> json_response_and_validate_schema(:ok) + + assert length(response) == 2 + end + + test "requires an admin even if a moderator can manage announcements" do + clear_config([:instance, :moderator_privileges], [:announcements_manage_announcements]) + + moderator = insert(:user, is_moderator: true) + token = insert(:oauth_admin_token, user: moderator) + + build_conn() + |> assign(:user, moderator) + |> assign(:token, token) + |> get("/api/pleroma/admin/webhooks") + |> json_response(:forbidden) + end + end + + describe "POST /api/pleroma/admin/webhooks" do + test "creates a webhook", %{conn: conn} do + %{"id" => id} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/webhooks", %{ + url: "http://example.com/webhook", + events: ["account.created"] + }) + |> json_response_and_validate_schema(:ok) + + assert %{url: "http://example.com/webhook", events: [:"account.created"]} = Webhook.get(id) + end + + test "rejects empty events", %{conn: conn} do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/webhooks", %{ + url: "http://example.com/webhook", + events: [] + }) + |> json_response(:bad_request) + end + + test "rejects internal flag", %{conn: conn} do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/webhooks", %{ + url: "http://example.com/webhook", + events: ["account.created"], + internal: true + }) + |> json_response(:bad_request) + end + end + + describe "PATCH /api/pleroma/admin/webhooks" do + test "edits a webhook", %{conn: conn} do + {:ok, %{id: id}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/webhooks/#{id}", %{ + events: ["report.created", "account.created"] + }) + |> json_response_and_validate_schema(:ok) + + assert %{events: [:"report.created", :"account.created"]} = Webhook.get(id) + end + + test "rejects empty events", %{conn: conn} do + {:ok, %{id: id}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/webhooks/#{id}", %{events: []}) + |> json_response(:bad_request) + + assert %{events: [:"report.created"]} = Webhook.get(id) + end + + test "rejects internal flag", %{conn: conn} do + {:ok, %{id: id}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/webhooks/#{id}", %{internal: true}) + |> json_response(:bad_request) + + assert %{internal: false} = Webhook.get(id) + end + + test "can't edit an internal webhook", %{conn: conn} do + {:ok, %{id: id}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [], internal: true}) + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/webhooks/#{id}", %{ + events: ["report.created", "account.created"] + }) + |> json_response_and_validate_schema(:forbidden) + + assert %{events: []} = Webhook.get(id) + end + end + + describe "DELETE /api/pleroma/admin/webhooks" do + test "deletes a webhook", %{conn: conn} do + {:ok, %{id: id}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/pleroma/admin/webhooks/#{id}") + |> json_response_and_validate_schema(:ok) + + assert [] = + Webhook + |> Pleroma.Repo.all() + end + end +end diff --git a/test/pleroma/webhook/notify_test.exs b/test/pleroma/webhook/notify_test.exs new file mode 100644 index 000000000..098be47c6 --- /dev/null +++ b/test/pleroma/webhook/notify_test.exs @@ -0,0 +1,125 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Webhook.NotifyTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Webhook + alias Pleroma.Webhook.Notify + + import Pleroma.Factory + + test "triggers a webhook for a report" do + %{id: activity_id} = activity = insert(:report_activity) + + url = "https://example.com/webhook" + + Webhook.create(%{url: url, events: [:"report.created"]}) + + Tesla.Mock.mock(fn %{url: ^url, body: body} = _ -> + report = + body + |> Jason.decode!() + |> Map.get("object") + + assert %{"id" => ^activity_id} = report + + %Tesla.Env{status: 200, body: ""} + end) + + [job] = Notify.trigger_webhooks(activity, :"report.created") + + Pleroma.Workers.WebhookWorker.perform(job) + end + + test "triggers a webhook for an account" do + %{id: account_id} = user = insert(:user) + + url = "https://example.com/webhook" + + Webhook.create(%{url: url, events: [:"account.created"]}) + + Tesla.Mock.mock(fn %{url: ^url, body: body} = _ -> + report = + body + |> Jason.decode!() + |> Map.get("object") + + assert %{"id" => ^account_id} = report + + %Tesla.Env{status: 200, body: ""} + end) + + [job] = Notify.trigger_webhooks(user, :"account.created") + + Pleroma.Workers.WebhookWorker.perform(job) + end + + test "notifies have a valid signature" do + activity = insert(:report_activity) + + {:ok, %{secret: secret} = webhook} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]}) + + Tesla.Mock.mock(fn %{url: "https://example.com/webhook", body: body, headers: headers} = _ -> + {"X-Hub-Signature", "sha256=" <> signature} = + Enum.find(headers, fn {key, _} -> key == "X-Hub-Signature" end) + + assert signature == :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower) + %Tesla.Env{status: 200, body: ""} + end) + + Notify.report_created(webhook, activity) + end + + test "returns an error for unsuccessful responses" do + user = insert(:user) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"account.created"]}) + + Tesla.Mock.mock(fn %{url: "https://example.com/webhook"} -> + %Tesla.Env{status: 503, body: ""} + end) + + assert {:error, {:http_status, 503}} = Notify.account_created(webhook, user) + end + + test "accepts any successful response" do + user = insert(:user) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"account.created"]}) + + Tesla.Mock.mock(fn %{url: "https://example.com/webhook"} -> + %Tesla.Env{status: 204, body: ""} + end) + + assert :ok = Notify.account_created(webhook, user) + end + + test "cancels queued deliveries when the webhook was deleted" do + user = insert(:user) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"account.created"]}) + + [job] = Notify.trigger_webhooks(user, :"account.created") + Webhook.delete(webhook) + + assert {:cancel, :not_found} = Pleroma.Workers.WebhookWorker.perform(job) + end + + test "cancels queued deliveries when the webhook was disabled" do + user = insert(:user) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"account.created"]}) + + [job] = Notify.trigger_webhooks(user, :"account.created") + Webhook.set_enabled(webhook, false) + + assert {:cancel, :disabled} = Pleroma.Workers.WebhookWorker.perform(job) + end +end diff --git a/test/pleroma/webhook_test.exs b/test/pleroma/webhook_test.exs new file mode 100644 index 000000000..eeae4e423 --- /dev/null +++ b/test/pleroma/webhook_test.exs @@ -0,0 +1,94 @@ +# 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.Webhook + + test "creating a webhook" do + {:ok, %{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 + {:ok, %{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 "rejects invalid events" do + assert {:error, _changeset} = + Webhook.create(%{url: "https://example.com/webhook", events: [:"status.created"]}) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/valid-webhook", events: [:"report.created"]}) + + assert {:error, _changeset} = Webhook.update(webhook, %{events: [:"status.created"]}) + end + + test "rejects URLs longer than the database column" do + url = "https://example.com/" <> String.duplicate("a", 256) + + assert {:error, changeset} = + Webhook.create(%{url: url, events: [:"report.created"]}) + + assert %{url: ["should be at most 255 character(s)"]} = errors_on(changeset) + end + + test "rejects empty events for non-internal webhooks" do + assert {:error, changeset} = Webhook.create(%{url: "https://example.com/webhook", events: []}) + assert %{events: ["can't be blank"]} = errors_on(changeset) + + {:ok, webhook} = + Webhook.create(%{url: "https://example.com/valid-webhook", events: [:"report.created"]}) + + assert {:error, changeset} = Webhook.update(webhook, %{events: []}) + assert %{events: ["can't be blank"]} = errors_on(changeset) + + assert {:ok, _webhook} = + Webhook.create(%{ + url: "https://example.com/internal-webhook", + events: [], + internal: true + }) + end + + test "filter webhooks by type" do + {:ok, %{id: id1}} = + Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]}) + + {:ok, %{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 + {:ok, %{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 + {:ok, %{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