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