diff --git a/.env.example b/.env.example index a5756e5..35961a2 100644 --- a/.env.example +++ b/.env.example @@ -48,4 +48,7 @@ BUGSNAG_API_KEY= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION= -AWS_BUCKET= \ No newline at end of file +AWS_BUCKET= + +EMAIL_OCTOPUS_API_KEY= +EMAIL_OCTOPUS_CONTACT_LISTS_VATGOODIES_NEWSLETTER= diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d53e4..c8e7dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Nothing yet... +## [1.1.0](https://github.com/vatsimgoodies/vatgoodies.com/compare/v1.0.2..v1.1.0) +### Added +Integration with EmailOctopus API. + ## [1.0.2](https://github.com/vatsimgoodies/vatgoodies.com/compare/v1.0.1...v1.0.2) - 2019-03-11 ### Added Dynamic discord link: https://vatgoodies.com/discord @@ -22,4 +26,4 @@ Dynamic discord link: https://vatgoodies.com/discord ### Changed - Back-ups are no longer including all application code files (only database) -[Unreleased]: https://github.com/vatsimgoodies/vatgoodies.com/compare/v1.0.2...HEAD +[Unreleased]: https://github.com/vatsimgoodies/vatgoodies.com/compare/v1.1.0...HEAD diff --git a/README.md b/README.md index a4d4274..d6146a8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -[![StyleCI](https://github.styleci.io/repos/121784641/shield?branch=develop)](https://github.styleci.io/repos/121784641) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/367a3d1bf509419aa1942d51c9fcb3c0)](https://www.codacy.com/app/roelgonzalez/vatgoodies.com?utm_source=github.com&utm_medium=referral&utm_content=vatsimgoodies/vatgoodies.com&utm_campaign=Badge_Grade) +[![StyleCI](https://github.styleci.io/repos/121784641/shield?branch=master)](https://github.styleci.io/repos/121784641) [![Maintainability](https://api.codeclimate.com/v1/badges/437bc5f2ee5dad338cc1/maintainability)](https://codeclimate.com/github/vatsimgoodies/vatgoodies.com/maintainability) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![Discord](https://img.shields.io/discord/545254906257342493.svg?color=7289DA&label=Discord&style=popout)](https://discord.gg/aQkKcf5) diff --git a/app/Http/Controllers/SubscriptionConfirmationController.php b/app/Http/Controllers/SubscriptionConfirmationController.php deleted file mode 100644 index b766da1..0000000 --- a/app/Http/Controllers/SubscriptionConfirmationController.php +++ /dev/null @@ -1,16 +0,0 @@ -processConfirmation($token); - - return redirect()->route('landing.show'); - } -} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 9743caf..3bc0643 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -2,14 +2,21 @@ namespace App\Http\Controllers; +use Illuminate\Http\Request; use App\Services\SubscriptionService; -use App\Http\Requests\StoreSubscription; class SubscriptionController extends Controller { - public function store(StoreSubscription $request, SubscriptionService $subscriptionService) + protected $subscriptionService; + + public function __construct(SubscriptionService $subscriptionService) { - $subscriptionService->processSubscriptionRequest($request); + $this->subscriptionService = $subscriptionService; + } + + public function store(Request $request, string $token) + { + $this->subscriptionService->storeSubscription($request, $token); return redirect()->route('landing.show'); } diff --git a/app/Http/Controllers/SubscriptionRequestController.php b/app/Http/Controllers/SubscriptionRequestController.php new file mode 100644 index 0000000..44e231b --- /dev/null +++ b/app/Http/Controllers/SubscriptionRequestController.php @@ -0,0 +1,26 @@ +subscriptionService = $subscriptionService; + } + + public function store(StoreSubscriptionRequest $request) + { + $this->subscriptionService->storeSubscriptionRequest($request); + + return redirect()->route('landing.show'); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index fb9616a..f8c4177 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -59,5 +59,6 @@ class Kernel extends HttpKernel 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'spam-protection' => \Spatie\Honeypot\ProtectAgainstSpam::class, ]; } diff --git a/app/Http/Requests/StoreSubscription.php b/app/Http/Requests/StoreSubscriptionRequest.php similarity index 94% rename from app/Http/Requests/StoreSubscription.php rename to app/Http/Requests/StoreSubscriptionRequest.php index 33bdaa0..6d8e5c3 100644 --- a/app/Http/Requests/StoreSubscription.php +++ b/app/Http/Requests/StoreSubscriptionRequest.php @@ -4,7 +4,7 @@ use Illuminate\Foundation\Http\FormRequest; -class StoreSubscription extends FormRequest +class StoreSubscriptionRequest extends FormRequest { /** * Determine if the user is authorized to make this request. diff --git a/app/Libraries/EmailOctopusApi.php b/app/Libraries/EmailOctopusApi.php new file mode 100644 index 0000000..860a367 --- /dev/null +++ b/app/Libraries/EmailOctopusApi.php @@ -0,0 +1,29 @@ +baseApiUrl = config('emailoctopus.general.base-api-url'); + $this->apiKey = config('emailoctopus.general.api-key'); + } + + public function createContactOfAList(string $listId, $emailAddress) + { + $url = sprintf('%s/lists/%s/contacts', $this->baseApiUrl, $listId); + + return Curl::to($url)->withData([ + 'api_key' => $this->apiKey, + 'email_address' => $emailAddress, + ])->asJsonRequest() + ->post(); + } +} diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php index 43b5a2e..beecac4 100644 --- a/app/Services/SubscriptionService.php +++ b/app/Services/SubscriptionService.php @@ -3,15 +3,41 @@ namespace App\Services; use App\Models\Subscription; +use Illuminate\Http\Request; +use App\Libraries\EmailOctopusApi; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Session; -use App\Http\Requests\StoreSubscription; +use App\Http\Requests\StoreSubscriptionRequest; use App\Mail\ConfirmYourSubscriptionMailable; class SubscriptionService { - public function processSubscriptionRequest(StoreSubscription $request) + /** + * @var EmailOctopusApi + */ + protected $emailOctopusApi; + + /** + * @var string + */ + protected $contactListId; + + public function __construct(EmailOctopusApi $emailOctopusApi) + { + $this->emailOctopusApi = $emailOctopusApi; + $this->contactListId = config('emailoctopus.contact-lists.vatgoodies_newsletter'); + } + + /** + * The method that will handle the store for SubscriptionRequestController. + * + * @param StoreSubscriptionRequest $request + * @return Subscription + */ + public function storeSubscriptionRequest(StoreSubscriptionRequest $request) { + $this->existsByEmail($request->email); + $subscription = new Subscription(); $subscription->email = $request->email; $subscription->token = md5(now()->timestamp); @@ -24,16 +50,56 @@ public function processSubscriptionRequest(StoreSubscription $request) return $subscription; } - public function processConfirmation($token) + /** + * The method that will handle the store for the SubscriptionController. + * + * @param Request $request + * @param string $token + * @return \Illuminate\Http\RedirectResponse|mixed + */ + public function storeSubscription(Request $request, string $token) { - $subscription = Subscription::where('token', $token)->first(); - + $subscription = $this->getByToken($token); + + if ($subscription === null) { + $request->session()->flash('failure', 'The requested e-mail could not be found, thus could not be confirmed.'); + return redirect()->to('landing.show'); + } + + $response = json_decode($this->emailOctopusApi->createContactOfAList($this->contactListId, $subscription->email)); + + if (isset($response->created_at) === false) { + $request->session()->flash('failure', 'Something went wrong during your confirmation, please try again.'); + return redirect()->to('landing.show'); + } + $subscription->confirmed = true; - $subscription->save(); Session::flash('success', 'You have successfully confirmed your subscription to stay posted about VATGoodies.com'); return $subscription; } + + /** + * Get subscription based on token. + * + * @param string $token + * @return mixed + */ + public function getByToken(string $token) + { + return Subscription::where('token', $token)->first(); + } + + /** + * See if a subscription exists based on given email. + * + * @param $email + * @return mixed + */ + public function existsByEmail($email) + { + return Subscription::where('email', $email)->exists(); + } } diff --git a/composer.json b/composer.json index e0fbbc7..88b65f3 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,10 @@ "type": "project", "require": { "php": "^7.1.3", + "ext-json": "*", "bugsnag/bugsnag-laravel": "^2.0", "fideloper/proxy": "^4.0", + "ixudra/curl": "^6.16", "laravel/envoy": "^1.4", "laravel/framework": "5.6.*", "laravel/tinker": "^1.0", diff --git a/composer.lock b/composer.lock index c0d403c..c92678a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "775b0b0541d3db95b32dd119b7042c7a", + "content-hash": "530383bcf0c9a6dc1bf641d1fe3fb94b", "packages": [ { "name": "aws/aws-sdk-php", - "version": "3.89.1", + "version": "3.90.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "9b085dff6b07eac417caef8cb6d26b471cde9a23" + "reference": "c4e20a477f2ec8c880a9821f9e81641b8e3bb696" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9b085dff6b07eac417caef8cb6d26b471cde9a23", - "reference": "9b085dff6b07eac417caef8cb6d26b471cde9a23", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c4e20a477f2ec8c880a9821f9e81641b8e3bb696", + "reference": "c4e20a477f2ec8c880a9821f9e81641b8e3bb696", "shasum": "" }, "require": { @@ -86,7 +86,7 @@ "s3", "sdk" ], - "time": "2019-03-08T19:15:34+00:00" + "time": "2019-03-11T18:09:51+00:00" }, { "name": "bugsnag/bugsnag", @@ -864,6 +864,59 @@ ], "time": "2018-12-04T20:46:45+00:00" }, + { + "name": "ixudra/curl", + "version": "6.16.0", + "source": { + "type": "git", + "url": "https://github.com/ixudra/curl.git", + "reference": "1ab270e48082ee6e7f32fa72412c2c81eb974516" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ixudra/curl/zipball/1ab270e48082ee6e7f32fa72412c2c81eb974516", + "reference": "1ab270e48082ee6e7f32fa72412c2c81eb974516", + "shasum": "" + }, + "require": { + "illuminate/support": ">=4.0", + "php": ">=5.4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Ixudra\\Curl\\CurlServiceProvider" + ], + "aliases": { + "Curl": "Ixudra\\Curl\\Facades\\Curl" + } + } + }, + "autoload": { + "psr-4": { + "Ixudra\\Curl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Oris", + "email": "jan.oris@ixudra.be" + } + ], + "description": "Custom PHP Curl library for the Laravel 5 framework - developed by Ixudra", + "homepage": "http://ixudra.be", + "keywords": [ + "Ixudra", + "curl", + "laravel" + ], + "time": "2017-12-08T14:31:13+00:00" + }, { "name": "jakub-onderka/php-console-color", "version": "v0.2", @@ -2287,25 +2340,28 @@ }, { "name": "swiftmailer/swiftmailer", - "version": "v6.1.3", + "version": "v6.2.0", "source": { "type": "git", "url": "https://github.com/swiftmailer/swiftmailer.git", - "reference": "8ddcb66ac10c392d3beb54829eef8ac1438595f4" + "reference": "6fa3232ff9d3f8237c0fae4b7ff05e1baa4cd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/8ddcb66ac10c392d3beb54829eef8ac1438595f4", - "reference": "8ddcb66ac10c392d3beb54829eef8ac1438595f4", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/6fa3232ff9d3f8237c0fae4b7ff05e1baa4cd707", + "reference": "6fa3232ff9d3f8237c0fae4b7ff05e1baa4cd707", "shasum": "" }, "require": { "egulias/email-validator": "~2.0", - "php": ">=7.0.0" + "php": ">=7.0.0", + "symfony/polyfill-iconv": "^1.0", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" }, "require-dev": { "mockery/mockery": "~0.9.1", - "symfony/phpunit-bridge": "~3.3@dev" + "symfony/phpunit-bridge": "^3.4.19|^4.1.8" }, "suggest": { "ext-intl": "Needed to support internationalized email addresses", @@ -2314,7 +2370,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.1-dev" + "dev-master": "6.2-dev" } }, "autoload": { @@ -2342,7 +2398,7 @@ "mail", "mailer" ], - "time": "2018-09-11T07:12:52+00:00" + "time": "2019-03-10T07:52:41+00:00" }, { "name": "symfony/console", @@ -2907,6 +2963,127 @@ ], "time": "2018-08-06T14:22:27+00:00" }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "97001cfc283484c9691769f51cdf25259037eba2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/97001cfc283484c9691769f51cdf25259037eba2", + "reference": "97001cfc283484c9691769f51cdf25259037eba2", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "time": "2018-09-21T06:26:08+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "89de1d44f2c059b266f22c9cc9124ddc4cd0987a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/89de1d44f2c059b266f22c9cc9124ddc4cd0987a", + "reference": "89de1d44f2c059b266f22c9cc9124ddc4cd0987a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php72": "^1.9" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "time": "2018-09-30T16:36:12+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.10.0", @@ -5082,7 +5259,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^7.1.3" + "php": "^7.1.3", + "ext-json": "*" }, "platform-dev": [] } diff --git a/config/emailoctopus.php b/config/emailoctopus.php new file mode 100644 index 0000000..c04859c --- /dev/null +++ b/config/emailoctopus.php @@ -0,0 +1,11 @@ + [ + 'api-key' => env('EMAIL_OCTOPUS_API_KEY'), + 'base-api-url' => 'https://emailoctopus.com/api/1.5', + ], + 'contact-lists' => [ + 'vatgoodies_newsletter' => env('EMAIL_OCTOPUS_CONTACT_LISTS_VATGOODIES_NEWSLETTER'), + ], +]; diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index 2aca664..2e95c4b 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -85,7 +85,7 @@ class="img-fluid"> @endforeach @endif -
+ {{ csrf_field() }} @honeypot
diff --git a/routes/web.php b/routes/web.php index 0a8bcdb..1148b69 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,11 +11,9 @@ | */ -use Spatie\Honeypot\ProtectAgainstSpam; - Route::get('/', ['uses' => 'LandingPageController@index', 'as' => 'landing.show']); -Route::post('/', ['uses' => 'SubscriptionController@store', 'as' => 'subscription.store'])->middleware(ProtectAgainstSpam::class); -Route::get('confirm-subscription/{token}', ['uses' => 'SubscriptionConfirmationController@store', 'as' => 'confirmation.store']); +Route::post('/', ['uses' => 'SubscriptionRequestController@store', 'as' => 'subscription-request.store'])->middleware('spam-protection'); +Route::get('confirm-subscription/{token}', ['uses' => 'SubscriptionController@store', 'as' => 'subscription.store']); Route::get('discord', function () { return redirect('https://discord.gg/aQkKcf5'); }); diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index b2bc36b..be6fda7 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -2,53 +2,56 @@ namespace Tests\Feature; -use App\Mail\ConfirmYourSubscriptionMailable; +use Mockery; +use Tests\TestCase; +use Mockery\MockInterface; use App\Models\Subscription; -use Illuminate\Foundation\Testing\DatabaseMigrations; -use Illuminate\Foundation\Testing\WithoutMiddleware; +use App\Libraries\EmailOctopusApi; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Mail; -use Tests\TestCase; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\RefreshDatabase; +use App\Mail\ConfirmYourSubscriptionMailable; +use Illuminate\Foundation\Testing\DatabaseMigrations; class SubscriptionTest extends TestCase { use DatabaseMigrations; - use WithoutMiddleware; - - /** @test */ - public function it_can_subscribe_to_stay_posted() + + /** @var MockInterface */ + protected $emailOctopusApi; + + protected function setUp() { - $this->post('/', [ - 'email' => 'interested@example.org' - ]); - - $subscriptions = Subscription::all(); - - $this->assertCount(1, $subscriptions); - $this->assertEquals('interested@example.org', $subscriptions->first()->email); + parent::setUp(); + + $this->emailOctopusApi = Mockery::mock(EmailOctopusApi::class); + $this->emailOctopusApi = $this->app->instance(EmailOctopusApi::class, $this->emailOctopusApi); + config()->set('honeypot.enabled', false); } - /** @test */ - public function it_can_not_subscribe_twice() + public function testItCanRequestToSubscribeToStayPosted() { + Mail::fake(); + $this->post('/', [ - 'email' => 'interested@example.org' + 'email' => 'interested@example.com' ]); - $response = $this->post('/', [ - 'email' => 'interested@example.org' - ]); - - $response->assertSessionHasErrors('email'); + $subscriptions = Subscription::all(); + + Mail::assertSent(ConfirmYourSubscriptionMailable::class); - $this->assertCount(1, Subscription::all()); + $this->assertCount(1, $subscriptions); + $this->assertEquals('interested@example.com', $subscriptions->first()->email); + $this->assertFalse((bool) $subscriptions->first()->confirmed); } - - /** @test */ - public function it_can_confirm_its_subscription() + + public function testItCanConfirmItsSubscription() { $this->withoutExceptionHandling(); + $this->emailOctopusApi->shouldReceive('createContactOfAList') + ->andReturn( + File::get(base_path('tests/stubs/email-octopus-api/create-contact-of-a-list/success.json')) + ); factory(Subscription::class)->create([ 'token' => $token = md5(now()->timestamp), @@ -65,15 +68,13 @@ public function it_can_confirm_its_subscription() $this->assertEquals(true, $confirmedSubscription->confirmed); } - /** @test */ - public function it_can_send_an_email_for_confirmation() + public function testItCannotSubscribeTwice() { - Mail::fake(); - - $this->post('/', [ - 'email' => 'roelgonzalez@example.org' - ]); + factory(Subscription::class)->create(['email' => 'interested@example.com']); + + $response = $this->post('/', ['email' => 'interested@example.com']); - Mail::assertSent(ConfirmYourSubscriptionMailable::class); + $response->assertSessionHasErrors('email'); + $this->assertCount(1, Subscription::all()); } } diff --git a/tests/stubs/email-octopus-api/create-contact-of-a-list/success.json b/tests/stubs/email-octopus-api/create-contact-of-a-list/success.json new file mode 100644 index 0000000..dec5a7a --- /dev/null +++ b/tests/stubs/email-octopus-api/create-contact-of-a-list/success.json @@ -0,0 +1,10 @@ +{ + "id": "00000000-43a9-11e9-a3c9-06b79b628af2", + "email_address": "interested@mailinator.com", + "fields": { + "FirstName": null, + "LastName": null + }, + "status": "SUBSCRIBED", + "created_at": "2019-03-11T02:57:46+00:00" +}