Commit c436a963 authored by Emma's avatar Emma 😻

added webhooks

parent 5b928aee
Pipeline #19776170 passed with stage
in 6 minutes and 51 seconds
......@@ -5,6 +5,7 @@
SITE_NAME=Postmill
NO_REPLY_ADDRESS="no-reply@example.com"
APP_LOCALE=en
APP_ENABLE_WEBHOOKS=0
###> symfony/framework-bundle ###
APP_ENV=dev
......
......@@ -20,6 +20,7 @@
"ext-iconv": "*",
"ext-pdo_pgsql": "*",
"doctrine/doctrine-migrations-bundle": "^1.2",
"eightpoints/guzzle-bundle": "^7.3",
"embed/embed": "^3.0",
"ezyang/htmlpurifier": "^4.8",
"friendsofsymfony/jsrouting-bundle": "^2.1",
......
This diff is collapsed.
......@@ -119,6 +119,26 @@ forum_list:
methods: [GET]
requirements: { forums: \d+, sortBy: by_name|by_title|by_subscribers|by_submissions }
forum_webhooks:
controller: App\Controller\ForumController::webhooks
methods: [GET]
path: /f/{forum_name}/webhooks
forum_add_webhook:
controller: App\Controller\ForumController::addWebhook
methods: [GET, POST]
path: /f/{forum_name}/add_webhook
forum_edit_webhook:
controller: App\Controller\ForumController::editWebhook
methods: [GET, POST]
path: /f/{forum_name}/edit_webhook/{webhook_id}
forum_remove_webhook:
controller: App\Controller\ForumController::removeWebhook
methods: [POST]
path: /f/{forum_name}/remove_webhook
forums_by_category:
controller: App\Controller\ForumController::listCategories
path: /forums/by_category
......
......@@ -21,4 +21,5 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true],
Gregwar\CaptchaBundle\GregwarCaptchaBundle::class => ['all' => true],
EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true],
];
eight_points_guzzle:
clients:
webhook_client:
options:
timeout: 5
# Configure headers.
# More info: http://docs.guzzlephp.org/en/stable/request-options.html#headers
headers:
User-Agent: "Postmill/0.6 (https://postmill.xyz/)"
Accept: application/json, */*
Content-Type: application/json
# Find plugins here:
# https://github.com/8p/EightPointsGuzzleBundle#known-and-supported-plugins
plugin: ~
......@@ -5,6 +5,7 @@ parameters:
user_forum_creation_interval: 1 day
uuid_regex: '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}'
wiki_page_regex: '[A-Za-z][A-Za-z0-9_-]*(/[A-Za-z][A-Za-z0-9_-]*)*'
env(APP_ENABLE_WEBHOOKS): false
services:
_defaults:
......@@ -40,6 +41,10 @@ services:
arguments:
$defaultLocale: "%env(APP_LOCALE)%"
App\Controller\ForumController:
arguments:
$enableWebhooks: "%env(bool:APP_ENABLE_WEBHOOKS)%"
App\Controller\UserController:
arguments:
$defaultLocale: "%env(APP_LOCALE)%"
......@@ -63,6 +68,11 @@ services:
- { name: doctrine.event_listener, event: postPersist }
- { name: kernel.event_listener, event: kernel.terminate }
App\EventListener\WebhookListener:
arguments:
$client: "@eight_points_guzzle.client.webhook_client"
$webhooksEnabled: "%env(bool:APP_ENABLE_WEBHOOKS)%"
App\Form\RequestPasswordResetType:
arguments:
$bypass: "@=parameter('kernel.environment') === 'test'"
......@@ -80,6 +90,7 @@ services:
App\Twig\AppExtension:
arguments:
$siteName: "%env(SITE_NAME)%"
$enableWebhooks: "%env(bool:APP_ENABLE_WEBHOOKS)%"
App\Utils\CachedMarkdownConverter:
arguments:
......
# About webhooks
A [webhook](https://en.wikipedia.org/wiki/Webhook) is a mechanism for notifying
over HTTP a third-party server when an event occurs. For instance, if a webhook
is configured to listen on 'new comment' events with the URL
`http://example.com/`, Postmill will send a POST request to that URL every time
a new comment is posted.
Currently, webhooks are added on a per-forum basis, and are managed by forum
moderators. The ability to add global webhooks is planned.
For security reasons, webhooks are disabled by default. If you trust your mods,
or you understand the security implications of letting your server make an
arbitrary number of HTTP requests to arbitrary servers when an event occurs, you
can set the `APP_ENABLE_WEBHOOKS` environment variable to `1` to enable
webhooks.
Performance
---
Webhooks are dispatched when Symfony's `kernel.terminate` event has been
dispatched. In practice, this means that setups which don't use PHP-FPM will
dispatch the webhooks before delivering the response, which can make events
seem slow to users.
Secret token
---
The secret token, if specified, will be included in the outgoing request via the
`X-Postmill-Secret` header.
Request bodies
---
### New submission
~~~json
{
"event": "new_submission",
"subject": {
"resource": "https://example.com/f/example/420/smoke-weed",
"id": 420,
"forum": "https://example.com/f/example",
"user": "https://example.com/user/emma",
"title": "Smoke weed!",
"body": "its good for your soul",
"url": "https://foo.example.com/",
"timestamp": "2018-04-20T06:09:00+00:00",
"locked": false,
"sticky": false,
"user_flag": "moderator",
"edited_at": "2018-06-09T04:21:09+00:00",
"moderated": false,
"comment_count": 0,
"upvotes": 0,
"downvotes": 0,
"thumbnail_1x": "https://example.com/very_long_image_url.jpg",
"thumbnail_2x": "https://example.com/very_long_image_url_2x.jpg"
}
}
~~~
Note that thumbnails are very unlikely to be available before the webhook has
been dispatched.
### Edit submission
~~~json
{
"event": "edit_submission",
"subject": {
"before": {
"resource": "https://example.com/f/example/420/smoke-weed",
"id": 420,
"forum": "https://example.com/f/example",
"user": "https://example.com/user/emma",
"title": "Smoke weed!",
"body": "its good for your soul",
"url": "https://foo.example.com/",
"timestamp": "2018-04-20T06:09:00+00:00",
"locked": false,
"sticky": false,
"user_flag": "moderator",
"moderated": false,
"comment_count": 69,
"upvotes": 420,
"downvotes": 69,
"thumbnail_1x": "https://example.com/very_long_image_url.jpg",
"thumbnail_2x": "https://example.com/very_long_image_url_2x.jpg"
},
"after": {
"resource": "https://example.com/f/example/420/smoke-weed",
"id": 420,
"forum": "https://example.com/f/example",
"user": "https://example.com/user/emma",
"title": "Smoke weed!",
"body": "actually don't",
"url": "https://foo.example.com/",
"timestamp": "2018-04-20T06:09:00+00:00",
"locked": false,
"sticky": false,
"user_flag": "moderator",
"edited_at": "2018-06-09T04:21:09+00:00",
"moderated": false,
"comment_count": 69,
"upvotes": 420,
"downvotes": 69,
"thumbnail_1x": "https://example.com/very_long_image_url.jpg",
"thumbnail_2x": "https://example.com/very_long_image_url_2x.jpg"
}
}
}
~~~
### New comment
~~~json
{
"event": "new_comment",
"subject": {
"resource": "https://example.com/f/example/420/comment/1312",
"id": 1312,
"body": "Raw body of comment",
"timestamp": "2018-04-20T06:09:00+00:00",
"user": "https://example.com/user/emma",
"submission": "https://example.com/f/example/420/smoke-weed",
"parent": "https://example.com/f/example/420/comment/69",
"reply_count": 0,
"upvotes": 0,
"downvotes": 0,
"soft_deleted": false,
"edited_at": "2018-06-09T04:21:09+00:00",
"moderated": false,
"user_flag": "admin"
}
}
~~~
### Edit comment
~~~json
{
"event": "edit_comment",
"subject": {
"before": {
"resource": "https://example.com/f/example/420/comment/1312",
"id": 1312,
"body": "Smoke a little weed",
"timestamp": "2018-04-20T06:09:00+00:00",
"user": "https://example.com/user/emma",
"submission": "https://example.com/f/example/420/smoke-weed",
"parent": "https://example.com/f/example/420/comment/69",
"reply_count": 42069,
"upvotes": 420,
"downvotes": 69,
"soft_deleted": false,
"moderated": false,
"user_flag": "admin"
},
"after": {
"resource": "https://example.com/f/example/420/comment/1312",
"id": 1312,
"body": "Smoke lots of weed",
"timestamp": "2018-04-20T06:09:00+00:00",
"user": "https://example.com/user/emma",
"submission": "https://example.com/f/example/420/smoke-weed",
"parent": "https://example.com/f/example/420/comment/69",
"reply_count": 42069,
"upvotes": 420,
"downvotes": 69,
"soft_deleted": false,
"edited_at": "2018-06-09T04:21:09+00:00",
"moderated": false,
"user_flag": "admin"
}
}
}
~~~
......@@ -7,6 +7,8 @@ use App\Entity\Forum;
use App\Entity\ForumLogCommentDeletion;
use App\Entity\Submission;
use App\Entity\User;
use App\Event\EntityModifiedEvent;
use App\Events;
use App\Form\CommentType;
use App\Form\Model\CommentData;
use App\Repository\CommentRepository;
......@@ -15,6 +17,8 @@ use App\Utils\Slugger;
use Doctrine\ORM\EntityManager;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
......@@ -72,11 +76,12 @@ final class CommentController extends AbstractController {
*
* @IsGranted("ROLE_USER")
*
* @param EntityManager $em
* @param Forum $forum
* @param Submission $submission
* @param Request $request
* @param Comment|null $comment
* @param EntityManager $em
* @param Forum $forum
* @param Submission $submission
* @param Request $request
* @param EventDispatcherInterface $dispatcher
* @param Comment|null $comment
*
* @return Response
*/
......@@ -85,6 +90,7 @@ final class CommentController extends AbstractController {
Forum $forum,
Submission $submission,
Request $request,
EventDispatcherInterface $dispatcher,
Comment $comment = null
) {
$data = new CommentData();
......@@ -100,6 +106,8 @@ final class CommentController extends AbstractController {
$em->persist($reply);
$em->flush();
$dispatcher->dispatch(Events::NEW_COMMENT, new GenericEvent($reply));
return $this->redirectToRoute('comment', [
'forum_name' => $forum->getName(),
'submission_id' => $submission->getId(),
......@@ -121,11 +129,12 @@ final class CommentController extends AbstractController {
*
* @IsGranted("edit", subject="comment")
*
* @param EntityManager $em
* @param Forum $forum
* @param Submission $submission
* @param Comment $comment
* @param Request $request
* @param EntityManager $em
* @param Forum $forum
* @param Submission $submission
* @param Comment $comment
* @param Request $request
* @param EventDispatcherInterface $dispatcher
*
* @return Response
*/
......@@ -134,7 +143,8 @@ final class CommentController extends AbstractController {
Forum $forum,
Submission $submission,
Comment $comment,
Request $request
Request $request,
EventDispatcherInterface $dispatcher
) {
$data = CommentData::createFromComment($comment);
......@@ -142,10 +152,14 @@ final class CommentController extends AbstractController {
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$before = clone $comment;
$data->updateComment($comment);
$em->flush();
$event = new EntityModifiedEvent($before, $comment);
$dispatcher->dispatch(Events::EDIT_COMMENT, $event);
return $this->redirectToRoute('comment', [
'forum_name' => $forum->getName(),
'submission_id' => $submission->getId(),
......
......@@ -3,13 +3,16 @@
namespace App\Controller;
use App\Entity\Forum;
use App\Entity\ForumWebhook;
use App\Entity\Moderator;
use App\Entity\User;
use App\Form\ForumAppearanceType;
use App\Form\ForumBanType;
use App\Form\ForumType;
use App\Form\ForumWebhookType;
use App\Form\Model\ForumBanData;
use App\Form\Model\ForumData;
use App\Form\Model\ForumWebhookData;
use App\Form\Model\ModeratorData;
use App\Form\ModeratorType;
use App\Form\PasswordConfirmType;
......@@ -17,8 +20,10 @@ use App\Repository\ForumBanRepository;
use App\Repository\ForumCategoryRepository;
use App\Repository\ForumLogEntryRepository;
use App\Repository\ForumRepository;
use App\Repository\ForumWebhookRepository;
use App\Repository\SubmissionRepository;
use Doctrine\ORM\EntityManager;
use Ramsey\Uuid\Uuid;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\HttpFoundation\Request;
......@@ -29,6 +34,15 @@ use Symfony\Component\HttpFoundation\Response;
* @Entity("user", expr="repository.findOneOrRedirectToCanonical(username, 'username')")
*/
final class ForumController extends AbstractController {
/**
* @var bool
*/
private $enableWebhooks;
public function __construct(bool $enableWebhooks) {
$this->enableWebhooks = $enableWebhooks;
}
/**
* Show the front page of a given forum.
*
......@@ -387,6 +401,135 @@ final class ForumController extends AbstractController {
]);
}
/**
* @IsGranted("moderator", subject="forum")
*
* @param Forum $forum
*
* @return Response
*/
public function webhooks(Forum $forum) {
if (!$this->enableWebhooks) {
throw $this->createNotFoundException('Webhooks are not enabled');
}
return $this->render('forum/webhooks.html.twig', [
'forum' => $forum,
]);
}
/**
* @IsGranted("moderator", subject="forum")
*
* @param Forum $forum
* @param Request $request
* @param EntityManager $em
*
* @return Response
*/
public function addWebhook(Forum $forum, Request $request, EntityManager $em) {
if (!$this->enableWebhooks) {
throw $this->createNotFoundException('Webhooks are not enabled');
}
$data = new ForumWebhookData();
$form = $this->createForm(ForumWebhookType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$webhook = $data->toWebhook($forum);
$em->persist($webhook);
$em->flush();
$this->addFlash('success', 'flash.webhook_added');
return $this->redirectToRoute('forum_webhooks', [
'forum_name' => $forum->getName(),
]);
}
return $this->render('forum/add_webhook.html.twig', [
'form' => $form->createView(),
'forum' => $forum,
]);
}
/**
* @Entity("webhook", expr="repository.findOneBy({forum: forum, id: webhook_id})")
* @IsGranted("moderator", subject="forum")
*
* @param Forum $forum
* @param ForumWebhook $webhook
* @param Request $request
* @param EntityManager $em
*
* @return Response
*/
public function editWebhook(Forum $forum, ForumWebhook $webhook, Request $request, EntityManager $em) {
if (!$this->enableWebhooks) {
throw $this->createNotFoundException('Webhooks are not enabled');
}
$data = new ForumWebhookData($webhook);
$form = $this->createForm(ForumWebhookType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data->updateWebhook($webhook);
$em->flush();
$this->addFlash('success', 'flash.webhook_edited');
return $this->redirectToRoute('forum_webhooks', [
'forum_name' => $forum->getName(),
]);
}
return $this->render('forum/edit_webhook.html.twig', [
'form' => $form->createView(),
'forum' => $forum,
]);
}
/**
* @IsGranted("moderator", subject="forum")
*
* @param Forum $forum
* @param Request $request
* @param ForumWebhookRepository $repository
* @param EntityManager $em
*
* @return Response
*/
public function removeWebhook(Forum $forum, Request $request, ForumWebhookRepository $repository, EntityManager $em) {
if (!$this->enableWebhooks) {
throw $this->createNotFoundException('Webhooks are not enabled');
}
$this->validateCsrf('remove_webhook', $request->request->get('token'));
$ids = (array) $request->request->get('webhook');
$ids = \array_filter($ids, function ($id) {
return \is_string($id) && Uuid::isValid($id);
});
$webhooks = $repository->findBy(['id' => $ids, 'forum' => $forum]);
foreach ($webhooks as $webhook) {
$em->remove($webhook);
}
$em->flush();
return $this->redirectToRoute('forum_webhooks', [
'forum_name' => $forum->getName(),
]);
}
/**
* @IsGranted("moderator", subject="forum")
*
......
......@@ -7,6 +7,8 @@ use App\Entity\Forum;
use App\Entity\ForumLogSubmissionDeletion;
use App\Entity\ForumLogSubmissionLock;
use App\Entity\Submission;
use App\Event\EntityModifiedEvent;
use App\Events;
use App\Form\DeleteReasonType;
use App\Form\Model\SubmissionData;
use App\Form\SubmissionType;
......@@ -14,6 +16,8 @@ use App\Utils\Slugger;
use Doctrine\ORM\EntityManager;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
......@@ -79,13 +83,19 @@ final class SubmissionController extends AbstractController {
*
* @IsGranted("ROLE_USER")
*
* @param EntityManager $em
* @param Request $request
* @param Forum $forum
* @param Forum|null $forum
* @param EntityManager $em
* @param Request $request
* @param EventDispatcherInterface $dispatcher
*
* @return Response
*/
public function submit(EntityManager $em, Request $request, Forum $forum = null) {
public function submit(
Forum $forum = null,
EntityManager $em,
Request $request,
EventDispatcherInterface $dispatcher
) {
$data = new SubmissionData($forum);
$form = $this->createForm(SubmissionType::class, $data);
......@@ -97,6 +107,8 @@ final class SubmissionController extends AbstractController {
$em->persist($submission);
$em->flush();
$dispatcher->dispatch(Events::NEW_SUBMISSION, new GenericEvent($submission));
return $this->redirectToRoute('submission', [
'forum_name' => $submission->getForum()->getName(),
'submission_id' => $submission->getId(),
......@@ -113,26 +125,37 @@ final class SubmissionController extends AbstractController {
/**
* @IsGranted("edit", subject="submission")
*
* @param EntityManager $em
* @param Forum $forum
* @param Submission $submission
* @param Request $request
* @param Forum $forum
* @param Submission $submission
* @param EntityManager $em
* @param Request $request
* @param EventDispatcherInterface $dispatcher
*
* @return Response
*/
public function editSubmission(EntityManager $em, Forum $forum, Submission $submission, Request $request) {
public function editSubmission(
Forum $forum,
Submission $submission,
EntityManager $em,
Request $request,
EventDispatcherInterface $dispatcher
) {
$data = SubmissionData::createFromSubmission($submission);
$form = $this->createForm(SubmissionType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$before = clone $submission;
$data->updateSubmission($submission);
$em->flush();
$this->addFlash('notice', 'flash.submission_edited');
$event = new EntityModifiedEvent($before, $submission);
$dispatcher->dispatch(Events::EDIT_SUBMISSION, $event);
return $this->redirectToRoute('submission', [
'forum_name' => $forum->getName(),
'submission_id' => $submission->getId(),
......
......@@ -130,6 +130,13 @@ class Forum {
*/
private $logEntries;
/**
* @ORM\OneToMany(targetEntity="ForumWebhook", mappedBy="forum")
*
* @var ForumWebhook[]|Collection
*/
private $webhooks;
public function __construct(
string $name,
string $title,
......@@ -148,6 +155,7 @@ class Forum {
$this->submissions = new ArrayCollection();
$this->subscriptions = new ArrayCollection();
$this->logEntries = new ArrayCollection();
$this->webhooks = new ArrayCollection();
if ($user) {
$this->addModerator(new Moderator($this, $user));
......@@ -390,6 +398,25 @@ class Forum {
}
}
/**
* @return ForumWebhook[]|Collection
*/
public function getWebhooks(): Collection {
return $this->webhooks;
}
/**
* @param string $event
*
* @return ForumWebhook[]
*/
public function getWebhooksByEvent(string $event): array {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('event', $event));
return $this->webhooks->matching($criteria)->toArray();
}
public static function normalizeName(string $name): string {
return mb_strtolower($name, 'UTF-8');
}
......
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
/**
* @ORM\Entity()
* @ORM\Table(name="forum_webhooks")
*/
class ForumWebhook {
public const EVENT_NEW_SUBMISSION = 'new_submission';
public const EVENT_EDIT_SUBMISSION = 'edit_submission';
public const EVENT_NEW_COMMENT = 'new_comment';
public const EVENT_EDIT_COMMENT = 'edit_comment';
public const EVENTS = [
self::EVENT_NEW_SUBMISSION,
self::EVENT_EDIT_SUBMISSION,
self::EVENT_NEW_COMMENT,
self::EVENT_EDIT_COMMENT,
];