From ec26c211d6c5e8b2e3726eea336408597b958d77 Mon Sep 17 00:00:00 2001 From: formsdev <136701234+formsdev@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:07:08 +0530 Subject: [PATCH] Better webhooks (#155) * Enable Pro plan - WIP * no pricing page if have no paid plans * Set pricing ids in env * views & submissions FREE for all * extra param for env * form password FREE for all * Custom Code is PRO feature * Replace codeinput prism with codemirror * Better form Cleaning message * Added risky user email spam protection * fix form cleaning * Custom SEO * fix custom seo formcleaner * Better webhooks * fix test case --- app/Listeners/FailedWebhookListener.php | 38 ++++ app/Listeners/Forms/NotifyFormSubmission.php | 199 ++++-------------- app/Listeners/Forms/PostFormDataToWebhook.php | 77 ------- app/Models/Forms/Form.php | 5 + app/Models/Integration/FormZapierWebhook.php | 15 +- .../Forms/FailedWebhookNotification.php | 57 +++++ app/Providers/EventServiceProvider.php | 7 +- app/Service/Forms/FormCleaner.php | 2 +- .../Forms/Webhooks/AbstractWebhookHandler.php | 66 ++++++ app/Service/Forms/Webhooks/DiscordHandler.php | 84 ++++++++ .../Forms/Webhooks/SimpleWebhookHandler.php | 24 +++ app/Service/Forms/Webhooks/SlackHandler.php | 75 +++++++ .../Forms/Webhooks/WebhookHandlerProvider.php | 32 +++ app/Service/Forms/Webhooks/ZapierHandler.php | 27 +++ .../views/mail/form/webhook-error.blade.php | 15 ++ 15 files changed, 478 insertions(+), 245 deletions(-) create mode 100644 app/Listeners/FailedWebhookListener.php delete mode 100644 app/Listeners/Forms/PostFormDataToWebhook.php create mode 100644 app/Notifications/Forms/FailedWebhookNotification.php create mode 100644 app/Service/Forms/Webhooks/AbstractWebhookHandler.php create mode 100644 app/Service/Forms/Webhooks/DiscordHandler.php create mode 100644 app/Service/Forms/Webhooks/SimpleWebhookHandler.php create mode 100644 app/Service/Forms/Webhooks/SlackHandler.php create mode 100644 app/Service/Forms/Webhooks/WebhookHandlerProvider.php create mode 100644 app/Service/Forms/Webhooks/ZapierHandler.php create mode 100644 resources/views/mail/form/webhook-error.blade.php diff --git a/app/Listeners/FailedWebhookListener.php b/app/Listeners/FailedWebhookListener.php new file mode 100644 index 0000000..c5c6f20 --- /dev/null +++ b/app/Listeners/FailedWebhookListener.php @@ -0,0 +1,38 @@ +meta['type'] == 'form_submission') { + $event->meta['form']->creator->notify(new FailedWebhookNotification($event)); + \Log::error('Failed form submission webhook', [ + 'webhook_url' => $event->webhookUrl, + 'exception' => $event->errorType, + 'message' => $event->errorMessage, + 'form_id' => $event->meta['form']->id + ]); + return; + } + + \Log::error('Failed webhook', [ + 'webhook_url' => $event->webhookUrl, + 'exception' => $event->errorType, + 'message' => $event->errorMessage + ]); + } +} diff --git a/app/Listeners/Forms/NotifyFormSubmission.php b/app/Listeners/Forms/NotifyFormSubmission.php index b8e59d7..a2da167 100644 --- a/app/Listeners/Forms/NotifyFormSubmission.php +++ b/app/Listeners/Forms/NotifyFormSubmission.php @@ -10,8 +10,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Notification; use App\Service\Forms\FormSubmissionFormatter; +use App\Service\Forms\Webhooks\WebhookHandlerProvider; use App\Notifications\Forms\FormSubmissionNotification; -use Vinkla\Hashids\Facades\Hashids; class NotifyFormSubmission implements ShouldQueue { @@ -25,165 +25,46 @@ class NotifyFormSubmission implements ShouldQueue */ public function handle(FormSubmitted $event) { - if (!$event->form->is_pro) return; + $this->sendEmailNotifications($event); - if ($event->form->notifies) { - // Send Email Notification - $subscribers = collect(preg_split("/\r\n|\n|\r/", $event->form->notification_emails))->filter(function($email) { + $this->sendWebhookNotification($event, WebhookHandlerProvider::SIMPLE_WEBHOOK_PROVIDER); + $this->sendWebhookNotification($event, WebhookHandlerProvider::SLACK_PROVIDER); + $this->sendWebhookNotification($event, WebhookHandlerProvider::DISCORD_PROVIDER); + foreach ($event->form->zappierHooks as $hook) { + $hook->triggerHook($event->data); + } + } + + private function sendWebhookNotification(FormSubmitted $event, string $provider) + { + WebhookHandlerProvider::getProvider( + $event->form, + $event->data, + $provider + )->handle(); + } + + /** + * Sends an email to each email address in the form's notification_emails field + * @param FormSubmitted $event + * @return void + */ + private function sendEmailNotifications(FormSubmitted $event) + { + if (!$event->form->is_pro || !$event->form->notifies) return; + + $subscribers = collect(preg_split("/\r\n|\n|\r/", $event->form->notification_emails))->filter(function ( + $email + ) { return filter_var($email, FILTER_VALIDATE_EMAIL); - }); - \Log::debug('Sending email notification',[ - 'recipients' => $subscribers->toArray(), - 'form_id' => $event->form->id, - 'form_slug' => $event->form->slug, - ]); - $subscribers->each(function ($subscriber) use ($event) { - Notification::route('mail', $subscriber)->notify(new FormSubmissionNotification($event)); - }); - } - - if ($event->form->notifies_slack) { - // Send Slack Notification - $this->sendSlackNotification($event); - } - - if ($event->form->notifies_discord) { - // Send Discord Notification - $this->sendDiscordNotification($event); - } - - - } - - - private function sendSlackNotification(FormSubmitted $event) - { - if($this->validateSlackWebhookUrl($event->form->slack_webhook_url)){ - $submissionString = ""; - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->outputStringsOnly(); - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; - $submissionString .= ">*".ucfirst($field['name'])."*: ".$tmpVal." \n"; - } - - $formURL = url("forms/".$event->form->slug); - $editFormURL = url("forms/".$event->form->slug."/show"); - $submissionId = Hashids::encode($event->data['submission_id']); - $externalLinks = [ - '*<'.$formURL.'|🔗 Open Form>*', - '*<'.$editFormURL.'|✍️ Edit Form>*' - ]; - if($event->form->editable_submissions){ - $externalLinks[] = '*<'.$event->form->share_url.'?submission_id='.$submissionId.'|✍️ '.$event->form->editable_submissions_button_text.'>*'; - } - - $finalSlackPostData = [ - 'blocks' => [ - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => 'New submission for your form *<'.$formURL.'|'.$event->form->title.':>*', - ] - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => $submissionString - ] - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => implode(' ', $externalLinks), - ] - ], - ] - ]; - - WebhookCall::create() - ->url($event->form->slack_webhook_url) - ->doNotSign() - ->payload($finalSlackPostData) - ->dispatch(); - } - } - - private function validateSlackWebhookUrl($url) - { - return ($url) ? str_contains($url, 'https://hooks.slack.com/') : false; - } - - private function sendDiscordNotification(FormSubmitted $event) - { - if($this->validateDiscordWebhookUrl($event->form->discord_webhook_url)){ - $submissionString = ""; - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->outputStringsOnly(); - - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; - $submissionString .= "**".ucfirst($field['name'])."**: `".$tmpVal."`\n"; - } - - $form_name = $event->form->title; - $form = Form::find($event->form->id); - $formURL = url("forms/".$event->form->slug."/show/submissions"); - - $finalDiscordPostData = [ - "content" => "@here We have received a new submission for **$form_name**", - "username" => config('app.name'), - "avatar_url" => asset('img/logo.png'), - "tts" => false, - "embeds" => [ - [ - "title" => "🔗 Go to $form_name", - - "type" => "rich", - - "description" => $submissionString, - - "url" => $formURL, - - "color" => hexdec(str_replace('#', '', $event->form->color)), - - "footer" => [ - "text" => config('app.name'), - "icon_url" => asset('img/logo.png'), - ], - - "author" => [ - "name" => config('app.name'), - "url" => config('app.url'), - ], - - "fields" => [ - [ - "name" => "Views 👀", - "value" => "$form->views_count", - "inline" => true - ], - [ - "name" => "Submissions 🖊️", - "value" => "$form->submissions_count", - "inline" => true - ] - ] - ] - ] - ]; - - WebhookCall::create() - ->url($event->form->discord_webhook_url) - ->doNotSign() - ->payload($finalDiscordPostData) - ->dispatch(); - } - } - - private function validateDiscordWebhookUrl($url) - { - return ($url) ? str_contains($url, 'https://discord.com/api/webhooks') : false; + }); + \Log::debug('Sending email notification', [ + 'recipients' => $subscribers->toArray(), + 'form_id' => $event->form->id, + 'form_slug' => $event->form->slug, + ]); + $subscribers->each(function ($subscriber) use ($event) { + Notification::route('mail', $subscriber)->notify(new FormSubmissionNotification($event)); + }); } } diff --git a/app/Listeners/Forms/PostFormDataToWebhook.php b/app/Listeners/Forms/PostFormDataToWebhook.php deleted file mode 100644 index 0ebd395..0000000 --- a/app/Listeners/Forms/PostFormDataToWebhook.php +++ /dev/null @@ -1,77 +0,0 @@ -form; - if (!$form->is_pro) return; - $data = $this->getWebhookData($event); - - $this->sendSimpleWebhook($form, $data); - $this->sendZappierWebhooks($form, $data); - } - - private function sendSimpleWebhook(Form $form, array $data) { - if ($form->webhook_url) { - \Log::debug('Sending data to webhook URL',[ - 'webhook_url' => $form->webhook_url, - 'form_id' => $form->id, - 'form_slug' => $form->slug, - ]); - WebhookCall::create() - ->url($form->webhook_url) - ->doNotSign() - ->payload($data) - ->dispatch(); - } - } - - private function sendZappierWebhooks(Form $form, array $data) { - foreach ($form->zappierHooks as $hook) { - \Log::debug('Sending data to Zapier webhook',[ - 'form_id' => $form->id, - 'form_slug' => $form->slug, - ]); - $hook->triggerHook($data); - } - } - - private function getWebhookData(FormSubmitted $event): array { - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->showHiddenFields(); - - $formattedData = []; - foreach ($formatter->getFieldsWithValue() as $field) { - $formattedData[$field['name']] = $field['value']; - } - - $submissionId = Hashids::encode($event->data['submission_id']); - $data = [ - 'form_title' => $event->form->title, - 'form_slug' => $event->form->slug, - 'submission' => $formattedData - ]; - if($event->form->editable_submissions){ - $data['edit_link'] = $event->form->share_url.'?submission_id='.$submissionId; - } - - return $data; - } -} diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index 9f061f4..c145828 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -140,6 +140,11 @@ class Form extends Model return url('/forms/'.$this->slug); } + public function getEditUrlAttribute() + { + return url('/forms/'.$this->slug.'/show'); + } + public function getSubmissionsCountAttribute() { return $this->submissions()->count(); diff --git a/app/Models/Integration/FormZapierWebhook.php b/app/Models/Integration/FormZapierWebhook.php index d8333e9..eb157c4 100644 --- a/app/Models/Integration/FormZapierWebhook.php +++ b/app/Models/Integration/FormZapierWebhook.php @@ -3,6 +3,7 @@ namespace App\Models\Integration; use App\Models\Forms\Form; +use App\Service\Forms\Webhooks\WebhookHandlerProvider; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -27,11 +28,13 @@ class FormZapierWebhook extends Model return $this->belongsTo(Form::class); } - public function triggerHook(array $data) { - WebhookCall::create() - ->url($this->hook_url) - ->doNotSign() - ->payload($data) - ->dispatch(); + public function triggerHook(array $data) + { + WebhookHandlerProvider::getProvider( + $this->form, + $data, + WebhookHandlerProvider::ZAPIER_PROVIDER, + $this->hook_url + )->handle(); } } diff --git a/app/Notifications/Forms/FailedWebhookNotification.php b/app/Notifications/Forms/FailedWebhookNotification.php new file mode 100644 index 0000000..230985a --- /dev/null +++ b/app/Notifications/Forms/FailedWebhookNotification.php @@ -0,0 +1,57 @@ +form = $this->event->meta['form']; + $this->provider = $this->event->meta['provider']; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + return (new MailMessage) + ->subject("Notification issue with your NotionForm: '" . $this->form->title . "'") + ->markdown('mail.form.webhook-error', [ + 'provider' => $this->provider, + 'error' => $this->event->errorMessage, + 'form' => $this->form, + ]); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index af12900..164266c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,16 +4,17 @@ namespace App\Providers; use App\Events\Forms\FormSubmitted; use App\Events\Models\FormCreated; +use App\Listeners\FailedWebhookListener; use App\Listeners\Auth\RegisteredListener; use App\Listeners\Forms\FormCreationConfirmation; use App\Listeners\Forms\NotifyFormSubmission; -use App\Listeners\Forms\PostFormDataToWebhook; use App\Listeners\Forms\SubmissionConfirmation; use App\Notifications\Forms\FormCreatedNotification; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Event; +use Spatie\WebhookServer\Events\WebhookCallFailedEvent; class EventServiceProvider extends ServiceProvider { @@ -31,8 +32,10 @@ class EventServiceProvider extends ServiceProvider ], FormSubmitted::class => [ NotifyFormSubmission::class, - PostFormDataToWebhook::class, SubmissionConfirmation::class, + ], + WebhookCallFailedEvent::class => [ + FailedWebhookListener::class ] ]; diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index 060305d..fc2889e 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -53,7 +53,7 @@ class FormCleaner 'discord_webhook_url' => "Discord webhook disabled.", 'editable_submissions' => 'Users will not be able to edit their submissions.', 'custom_code' => 'Custom code was disabled', - 'seo_meta' => 'Custom code was disabled', + 'seo_meta' => 'Custom SEO was disabled', // For fields 'file_upload' => "Link field is not a file upload.", diff --git a/app/Service/Forms/Webhooks/AbstractWebhookHandler.php b/app/Service/Forms/Webhooks/AbstractWebhookHandler.php new file mode 100644 index 0000000..a76d844 --- /dev/null +++ b/app/Service/Forms/Webhooks/AbstractWebhookHandler.php @@ -0,0 +1,66 @@ +form, $this->data))->showHiddenFields(); + + $formattedData = []; + foreach ($formatter->getFieldsWithValue() as $field) { + $formattedData[$field['name']] = $field['value']; + } + + $data = [ + 'form_title' => $this->form->title, + 'form_slug' => $this->form->slug, + 'submission' => $formattedData, + ]; + if ($this->form->is_pro && $this->form->editable_submissions) { + $data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode($this->data['submission_id']); + } + + return $data; + } + + abstract protected function shouldRun(): bool; + + public function handle() + { + if (!$this->shouldRun()) return; + + WebhookCall::create() + // Add context on error, used to notify form owner + ->meta([ + 'type' => 'form_submission', + 'data' => $this->data, + 'form' => $this->form, + 'provider' => $this->getProviderName(), + ]) + ->url($this->getWebhookUrl()) + ->doNotSign() + ->payload($this->getWebhookData()) + ->dispatchSync(); + } +} diff --git a/app/Service/Forms/Webhooks/DiscordHandler.php b/app/Service/Forms/Webhooks/DiscordHandler.php new file mode 100644 index 0000000..446f217 --- /dev/null +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -0,0 +1,84 @@ +form->discord_webhook_url; + } + + protected function getWebhookData(): array + { + $submissionString = ""; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); + + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; + $submissionString .= "**" . ucfirst($field['name']) . "**: `" . $tmpVal . "`\n"; + } + + $form_name = $this->form->title; + $formURL = url("forms/" . $this->form->slug . "/show/submissions"); + + return [ + "content" => "@here We have received a new submission for **$form_name**", + "username" => config('app.name'), + "avatar_url" => asset('img/logo.png'), + "tts" => false, + "embeds" => [ + [ + "title" => "🔗 Go to $form_name", + + "type" => "rich", + + "description" => $submissionString, + + "url" => $formURL, + + "color" => hexdec(str_replace('#', '', $this->form->color)), + + "footer" => [ + "text" => config('app.name'), + "icon_url" => asset('img/logo.png'), + ], + + "author" => [ + "name" => config('app.name'), + "url" => config('app.url'), + ], + + "fields" => [ + [ + "name" => "Views 👀", + "value" => (string)$this->form->views_count, + "inline" => true + ], + [ + "name" => "Submissions 🖊️", + "value" => (string)$this->form->submissions_count, + "inline" => true + ] + ] + ] + ] + ]; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) + && str_contains($this->getWebhookUrl(), 'https://discord.com/api/webhooks') + && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/SimpleWebhookHandler.php b/app/Service/Forms/Webhooks/SimpleWebhookHandler.php new file mode 100644 index 0000000..4d3add0 --- /dev/null +++ b/app/Service/Forms/Webhooks/SimpleWebhookHandler.php @@ -0,0 +1,24 @@ +form->webhook_url; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/SlackHandler.php b/app/Service/Forms/Webhooks/SlackHandler.php new file mode 100644 index 0000000..2ed292d --- /dev/null +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -0,0 +1,75 @@ +form->slack_webhook_url; + } + + protected function getWebhookData(): array + { + $submissionString = ''; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; + $submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n"; + } + + $formURL = url('forms/' . $this->form->slug); + $editFormURL = url('forms/' . $this->form->slug . '/show'); + $submissionId = Hashids::encode($this->data['submission_id']); + $externalLinks = [ + '*<' . $formURL . '|🔗 Open Form>*', + '*<' . $editFormURL . '|✍️ Edit Form>*' + ]; + if ($this->form->editable_submissions) { + $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*'; + } + + return [ + 'blocks' => [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'New submission for your form *<' . $formURL . '|' . $this->form->title . ':>*', + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $submissionString, + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => implode(' ', $externalLinks), + ], + ], + ], + ]; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) + && str_contains($this->getWebhookUrl(), 'https://hooks.slack.com/') + && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/WebhookHandlerProvider.php b/app/Service/Forms/Webhooks/WebhookHandlerProvider.php new file mode 100644 index 0000000..a271aa0 --- /dev/null +++ b/app/Service/Forms/Webhooks/WebhookHandlerProvider.php @@ -0,0 +1,32 @@ +webhookUrl; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()); + } +} diff --git a/resources/views/mail/form/webhook-error.blade.php b/resources/views/mail/form/webhook-error.blade.php new file mode 100644 index 0000000..4df9a31 --- /dev/null +++ b/resources/views/mail/form/webhook-error.blade.php @@ -0,0 +1,15 @@ +@component('mail::message') + +Hello, + +We tried to trigger a **{{$provider}}** notification for your form "{{$form->title}}", but it failed. Here is the error that we got: + +@component('mail::panel') +{{$error}} +@endcomponent + +Click [here to edit your form]({{$form->edit_url}}). + +Contact us via the website live chat if you need any help. + +@endcomponent