diff --git a/changelog.d/webhooks.add b/changelog.d/webhooks.add
new file mode 100644
index 0000000000..323428f9f6
--- /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/docs/development/API/admin_api.md b/docs/development/API/admin_api.md
index 182a760faa..dad8ff55c5 100644
--- a/docs/development/API/admin_api.md
+++ b/docs/development/API/admin_api.md
@@ -1751,3 +1751,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/web/router.ex b/lib/pleroma/web/router.ex
index 162e9614bd..1df067aca0 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -317,6 +317,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
index 6e61455f28..b9d9c5d296 100644
--- a/lib/pleroma/webhook.ex
+++ b/lib/pleroma/webhook.ex
@@ -33,7 +33,7 @@ def get_by_type(type) do
def changeset(%__MODULE__{} = webhook, params) do
webhook
- |> cast(params, [:url, :events, :enabled, :internal])
+ |> cast(params, [:url, :events, :enabled])
|> validate_required([:url, :events])
|> unique_constraint(:url)
|> strip_events()
@@ -42,7 +42,7 @@ def changeset(%__MODULE__{} = webhook, params) do
def update_changeset(%__MODULE__{} = webhook, params \\ %{}) do
webhook
- |> cast(params, [:url, :events, :enabled, :internal])
+ |> cast(params, [:url, :events, :enabled])
|> unique_constraint(:url)
|> strip_events()
end
diff --git a/test/pleroma/webhook/notify_test.ex b/test/pleroma/webhook/notify_test.ex
new file mode 100644
index 0000000000..8aa9de08c6
--- /dev/null
+++ b/test/pleroma/webhook/notify_test.ex
@@ -0,0 +1,29 @@
+# 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 "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.ex b/test/pleroma/webhook_test.ex
new file mode 100644
index 0000000000..21763f1e00
--- /dev/null
+++ b/test/pleroma/webhook_test.ex
@@ -0,0 +1,57 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.WebhookTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Repo
+ alias Pleroma.Webhook
+
+ test "creating a webhook" do
+ %{id: id} = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]})
+
+ assert %{url: "https://example.com/webhook"} = Webhook.get(id)
+ end
+
+ test "editing a webhook" do
+ %{id: id} =
+ webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]})
+
+ Webhook.update(webhook, %{events: [:"account.created"]})
+
+ assert %{events: [:"account.created"]} = Webhook.get(id)
+ end
+
+ test "filter webhooks by type" do
+ %{id: id1} =
+ Webhook.create(%{url: "https://example.com/webhook1", events: [:"report.created"]})
+
+ %{id: id2} =
+ Webhook.create(%{
+ url: "https://example.com/webhook2",
+ events: [:"account.created", :"report.created"]
+ })
+
+ Webhook.create(%{url: "https://example.com/webhook3", events: [:"account.created"]})
+
+ assert [%{id: ^id1}, %{id: ^id2}] = Webhook.get_by_type(:"report.created")
+ end
+
+ test "change webhook state" do
+ %{id: id, enabled: true} =
+ webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]})
+
+ Webhook.set_enabled(webhook, false)
+ assert %{enabled: false} = Webhook.get(id)
+ end
+
+ test "rotate webhook secrets" do
+ %{id: id, secret: secret} =
+ webhook = Webhook.create(%{url: "https://example.com/webhook", events: [:"report.created"]})
+
+ Webhook.rotate_secret(webhook)
+ %{secret: new_secret} = Webhook.get(id)
+ assert secret != new_secret
+ end
+end