From 9d7bdab7a2d9808e8c6dc77531f99d653364d61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 17:07:09 +0200 Subject: [PATCH] Mastodon-compatible webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/webhooks.add | 1 + config/config.exs | 3 +- config/description.exs | 20 ++ docs/development/API/admin_api.md | 89 ++++++++ lib/pleroma/application.ex | 3 +- lib/pleroma/user.ex | 2 + lib/pleroma/web/activity_pub/activity_pub.ex | 5 +- .../controllers/webhook_controller.ex | 88 ++++++++ .../web/admin_api/views/webhook_view.ex | 33 +++ lib/pleroma/web/api_spec.ex | 3 +- .../operations/admin/webhook_operation.ex | 193 ++++++++++++++++++ .../operations/notification_operation.ex | 1 - .../mastodon_api/admin/views/account_view.ex | 65 ++++++ .../mastodon_api/admin/views/report_view.ex | 54 +++++ lib/pleroma/web/router.ex | 9 + lib/pleroma/webhook.ex | 100 +++++++++ lib/pleroma/webhook/notify.ex | 83 ++++++++ lib/pleroma/workers/webhook_worker.ex | 41 ++++ .../20220624104914_create_webhooks.exs | 20 ++ test/mix/tasks/pleroma/database_test.exs | 3 +- test/pleroma/user_test.exs | 10 + .../web/activity_pub/activity_pub_test.exs | 24 +++ .../controllers/webhook_controller_test.exs | 84 ++++++++ test/pleroma/webhook/notify_test.exs | 75 +++++++ test/pleroma/webhook_test.exs | 56 +++++ 25 files changed, 1059 insertions(+), 6 deletions(-) create mode 100644 changelog.d/webhooks.add create mode 100644 lib/pleroma/web/admin_api/controllers/webhook_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/webhook_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/account_view.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/report_view.ex create mode 100644 lib/pleroma/webhook.ex create mode 100644 lib/pleroma/webhook/notify.ex create mode 100644 lib/pleroma/workers/webhook_worker.ex create mode 100644 priv/repo/migrations/20220624104914_create_webhooks.exs create mode 100644 test/pleroma/web/admin_api/controllers/webhook_controller_test.exs create mode 100644 test/pleroma/webhook/notify_test.exs create mode 100644 test/pleroma/webhook_test.exs 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 683805fe3..b01f1ffcc 100644 --- a/config/config.exs +++ b/config/config.exs @@ -926,7 +926,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 c61a344e8..764137674 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3479,6 +3479,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: 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/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index 64c06ca2b..ee82c077d 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -1806,3 +1806,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 + - *optional* `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 8e1c5de0d..171884639 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -278,7 +278,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 468e124b5..f9f5c15f7 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 @@ -980,6 +981,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 3e5239a28..deaf249eb 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/webhook_controller.ex b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex new file mode 100644 index 000000000..8a6b0de7a --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex @@ -0,0 +1,88 @@ +# 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 webhook <- Webhook.create(params) do + render(conn, "show.json", webhook: webhook) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id), + webhook <- Webhook.update(webhook, params) 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 000000000..725183029 --- /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.ex b/lib/pleroma/web/api_spec.ex index 3e0ac3704..71d9f08f7 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" ] }, %{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]}, 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..c83caa555 --- /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 94d1f6b82..ded5e725d 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/mastodon_api/admin/views/account_view.ex b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex new file mode 100644 index 000000000..40fac94ca --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI + + def render("index.json", %{users: users}) do + render_many(users, __MODULE__, "show.json", as: :user) + end + + def render("show.json", %{user: user}) do + account = + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + + %{ + id: user.id, + username: username_from_nickname(user.nickname), + domain: domain_from_nickname(user.nickname), + created_at: Utils.to_masto_date(user.inserted_at), + email: user.email, + ip: nil, + role: role(user), + confirmed: user.is_confirmed, + sensitized: nil, + suspened: nil, + silenced: nil, + disabled: !user.is_active, + approved: user.is_approved, + locale: nil, + invite_request: user.registration_reason, + ips: nil, + account: account + } + end + + defp username_from_nickname(string) when is_binary(string) do + hd(String.split(string, "@")) + end + + defp username_from_nickname(_), do: nil + + defp domain_from_nickname(string) when is_binary(string) do + String.split(string, "@") + |> Enum.at(1, nil) + end + + defp domain_from_nickname(_), do: nil + + defp role(%User{is_admin: true}) do + "admin" + end + + defp role(%User{is_moderator: true}) do + "moderator" + end + + defp role(_user) do + nil + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/views/report_view.ex b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex new file mode 100644 index 000000000..dd8dec8e3 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.Admin.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{reports: reports}) do + reports + |> Enum.map(&Report.extract_report_info/1) + |> Enum.map(&render(__MODULE__, "show.json", &1)) + end + + def render("show.json", %{ + report: report, + user: account, + account: target_account, + statuses: statuses + }) do + created_at = Utils.to_masto_date(report.data["published"]) + + content = + unless is_nil(report.data["content"]) do + HTML.filter_tags(report.data["content"]) + else + nil + end + + %{ + id: report.id, + action_taken: report.data["state"] != "open", + category: "other", + comment: content, + created_at: created_at, + updated_at: created_at, + account: AccountView.render("show.json", %{user: account}), + target_account: AccountView.render("show.json", %{user: target_account}), + assigned_account: nil, + action_taken_by_account: nil, + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), + rules: [] + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index cd9cfd3ed..e3c2f3ff7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -320,6 +320,15 @@ defmodule Pleroma.Web.Router do get("/announcements/:id", AnnouncementController, :show) patch("/announcements/:id", AnnouncementController, :change) delete("/announcements/:id", AnnouncementController, :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..6cf47fd68 --- /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) + field(:events, {:array, Ecto.Enum}, values: @event_types, default: []) + field(:secret, :string, default: "") + field(:enabled, :boolean, 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 000000000..7dbfc52a0 --- /dev/null +++ b/lib/pleroma/webhook/notify.ex @@ -0,0 +1,83 @@ +# 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)}"} + ] + + 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/lib/pleroma/workers/webhook_worker.ex b/lib/pleroma/workers/webhook_worker.ex new file mode 100644 index 000000000..b4329b775 --- /dev/null +++ b/lib/pleroma/workers/webhook_worker.ex @@ -0,0 +1,41 @@ +# 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 + + 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 + webhook = Pleroma.Webhook.get(webhook_id) + report = Activity.get_by_id(report_id) + + Pleroma.Webhook.Notify.report_created(webhook, report) + end + + def perform(%Job{ + args: %{ + "op" => "notify", + "type" => "account.created", + "webhook_id" => webhook_id, + "user_id" => user_id + } + }) do + webhook = Pleroma.Webhook.get(webhook_id) + user = Pleroma.User.get_by_id(user_id) + + Pleroma.Webhook.Notify.account_created(webhook, user) + 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/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs index 38ed096ae..c7dba7771 100644 --- a/test/mix/tasks/pleroma/database_test.exs +++ b/test/mix/tasks/pleroma/database_test.exs @@ -416,7 +416,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 0b4dc9197..04ec3a32e 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 7fc4f47cd..771af7639 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 @@ -1715,6 +1716,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..6a1586ff1 --- /dev/null +++ b/test/pleroma/web/admin_api/controllers/webhook_controller_test.exs @@ -0,0 +1,84 @@ +# 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: true + + 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 + 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 + end + + describe "PATCH /api/pleroma/admin/webhooks" do + test "edits a webhook", %{conn: conn} do + %{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 + end + + describe "DELETE /api/pleroma/admin/webhooks" do + test "deletes a webhook", %{conn: conn} do + %{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..e03890aab --- /dev/null +++ b/test/pleroma/webhook/notify_test.exs @@ -0,0 +1,75 @@ +# 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) + + %{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() + %Tesla.Env{status: 200, body: ""} + end) + + Notify.report_created(webhook, activity) + end +end diff --git a/test/pleroma/webhook_test.exs b/test/pleroma/webhook_test.exs new file mode 100644 index 000000000..ea1378730 --- /dev/null +++ b/test/pleroma/webhook_test.exs @@ -0,0 +1,56 @@ +# 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 + %{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