diff --git a/.env.example b/.env.example index ba7b6c9ff..c7729c2f2 100644 --- a/.env.example +++ b/.env.example @@ -41,4 +41,7 @@ BASIC_AUTH_PASSWORD=secret RESPONSE_CACHE_ENABLED=false -INSTAGRAM_TOKEN= \ No newline at end of file +INSTAGRAM_TOKEN= + +PATREON_CLIENT_ID= +PATREON_SECRET= diff --git a/app/Console/Commands/ImportPatreonPledgers.php b/app/Console/Commands/ImportPatreonPledgers.php new file mode 100644 index 000000000..1f5e87437 --- /dev/null +++ b/app/Console/Commands/ImportPatreonPledgers.php @@ -0,0 +1,73 @@ +patreon = $patreon; + + parent::__construct(); + } + + public function handle() + { + $this->info('Importing pledgers from Patreon...'); + + $this->removePreviousPledgers(); + + $campaigns = $this->patreon->campaigns(); + + if ($campaigns->count() === 0) { + throw new Exception("No Patreon campaigns found."); + } + + $this->getPledges($campaigns->first())->each(function (Pledge $pledge) { + PatreonPledger::import($pledge->user); + }); + + $this->info('All done!'); + } + + protected function getPledges(Campaign $campaign): Collection + { + $rewards = $this->getSuitableRewards($campaign); + + return $this->patreon->pledges($campaign->id)->filter(function (Pledge $pledge) use ($rewards) { + return $rewards->contains(function (Reward $reward) use ($pledge) { + return $reward->id === $pledge->rewardId; + }); + }); + } + + protected function getSuitableRewards(Campaign $campaign): Collection + { + $minimalAmount = 5000; + + return $campaign->rewards->filter(function (Reward $reward) use ($minimalAmount) { + return $reward->amount >= $minimalAmount; + })->values(); + } + + protected function removePreviousPledgers() + { + PatreonPledger::query()->truncate(); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1af54db39..37f1538f3 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -14,6 +14,7 @@ protected function schedule(Schedule $schedule) $schedule->command('import:random-contributor')->hourly(); $schedule->command('import:packagist-downloads')->hourly(); $schedule->command('import:github-repositories')->daily(); + $schedule->command('import:patreon-pledgers')->daily(); } protected function commands() diff --git a/app/Http/Controllers/OpenSourceController.php b/app/Http/Controllers/OpenSourceController.php index f0c2c224c..9769b764a 100644 --- a/app/Http/Controllers/OpenSourceController.php +++ b/app/Http/Controllers/OpenSourceController.php @@ -5,6 +5,7 @@ use App\Http\Resources\RepositoryResource; use App\Models\Contributor; use App\Models\Issue; +use App\Models\PatreonPledger; use App\Models\Repository; class OpenSourceController extends Controller @@ -19,7 +20,9 @@ public function index() $contributor = Contributor::first(); - return view('pages.open-source.index', compact('repositories', 'issues', 'contributor')); + $patreonPledger = PatreonPledger::get()->random(); + + return view('pages.open-source.index', compact('repositories', 'issues', 'contributor', 'patreonPledger')); } public function packages() diff --git a/app/Http/Resources/PatreonResource.php b/app/Http/Resources/PatreonResource.php new file mode 100644 index 000000000..941e6ef16 --- /dev/null +++ b/app/Http/Resources/PatreonResource.php @@ -0,0 +1,19 @@ +id)->count() > 0) { + return; + } + + $model = new static(); + + $model->patreon_id = $user->id; + $model->name = $user->name; + $model->avatar_url = $user->avatarUrl; + + $model->save(); + } + + public function getRespectPhraseAttribute() + { + return collect([ + "Thank your for your pledge", + "You sir/madam are awesome", + "We eat our monthly pasta thanks to you", + "Your actions are heart-warming", + ])->random(); + } +} diff --git a/app/Services/Patreon/Patreon.php b/app/Services/Patreon/Patreon.php new file mode 100644 index 000000000..e2dc541ed --- /dev/null +++ b/app/Services/Patreon/Patreon.php @@ -0,0 +1,105 @@ +client = $client; + } + + public function campaigns(): Collection + { + $data = $this->request("current_user/campaigns?include=pledges,rewards"); + + return $this->importCampaigns($data); + } + + public function pledges(int $campaignId): Collection + { + return $this->fetchPledges("campaigns/{$campaignId}/pledges?include=patron.null,reward"); + } + + protected function request(string $endpoint): array + { + $response = $this->client->get($endpoint); + + return json_decode($response->getBody(), true); + } + + protected function fetchPledges(string $endpoint): Collection + { + $data = $this->request($endpoint); + + $pledges = $this->importPledges($data); + + if (array_key_exists('next', $data['links'])) { + $pledges = $pledges->merge($this->fetchPledges($data['links']['next'])); + } + + return $pledges; + } + + protected function importCampaigns(array $data): Collection + { + $rewards = $this->importRewards($data); + + return collect($data['data'])->map(function (array $item) use ($rewards) { + $campagin = Campaign::import($item); + + $campagin->rewards = collect($item['relationships']['rewards']['data']) + ->map(function ($item) use ($rewards) { + return $rewards->first(function (Reward $reward) use ($rewards, $item) { + return $reward->id === (int ) $item['id']; + }); + }); + + return $campagin; + }); + } + + protected function importPledges(array $data) + { + $users = $this->importUsers($data); + $rewards = $this->importRewards($data); + + return collect($data['data'])->map(function (array $pledge) use ($rewards, $users) { + $pledge = Pledge::import($pledge); + + $pledge->user = $users->first(function (User $user) use ($pledge) { + return $user->id === $pledge->userId; + }); + + return $pledge; + }); + } + + protected function importUsers(array $data): Collection + { + return collect($data['included'])->filter(function (array $item) { + return $item['type'] === 'user'; + })->map(function ($item) { + return User::import($item); + })->values(); + } + + protected function importRewards(array $data): Collection + { + return collect($data['included'])->filter(function (array $item) { + return $item['type'] === 'reward'; + })->map(function ($item) { + return Reward::import($item); + })->values(); + } +} diff --git a/app/Services/Patreon/PatreonAuthenticator.php b/app/Services/Patreon/PatreonAuthenticator.php new file mode 100644 index 000000000..bfd03fd39 --- /dev/null +++ b/app/Services/Patreon/PatreonAuthenticator.php @@ -0,0 +1,82 @@ +client = new Client([ + 'base_uri' => 'https://api.patreon.com/oauth2/token', + ]); + + $this->valueStore = Valuestore::make(storage_path('/app/patreon-access.json')); + + $this->clientId = $clientId; + + $this->clientSecret = $clientSecret; + } + + public function autoRefresh(string $refreshToken = null) : array + { + $tokens = $this->getTokens(); + + return $this->refresh($refreshToken ?? $tokens['refresh_token']); + } + + protected function refresh($refreshToken) : array + { + $data = [ + "grant_type" => "refresh_token", + "refresh_token" => $refreshToken, + "client_id" => $this->clientId, + "client_secret" => $this->clientSecret, + ]; + + try { + $response = $this->client->request('post', 'token', [ + 'query' => $data, + ]); + } catch (RequestException $exception) { + return $this->getTokens(); + } + + $tokens = json_decode($response->getBody(), true); + + $this->saveTokens($tokens); + + return $tokens; + } + + protected function saveTokens($tokens) + { + $this->valueStore->put('access_token', $tokens['access_token']); + $this->valueStore->put('refresh_token', $tokens['refresh_token']); + } + + protected function getTokens(): array + { + $tokens = []; + + $tokens['access_token'] = $this->valueStore->get('access_token'); + $tokens['refresh_token'] = $this->valueStore->get('refresh_token'); + + return $tokens; + } +} diff --git a/app/Services/Patreon/PatreonServiceProvider.php b/app/Services/Patreon/PatreonServiceProvider.php new file mode 100644 index 000000000..434d8425d --- /dev/null +++ b/app/Services/Patreon/PatreonServiceProvider.php @@ -0,0 +1,32 @@ +app->singleton(Patreon::class, function () { + $authenticator = new PatreonAuthenticator(config('services.patreon.id'), config('services.patreon.secret')); + + $tokens = $authenticator->autoRefresh(); + + $client = $this->buildClient($tokens['access_token']); + + return new Patreon($client); + }); + } + + protected function buildClient($accessToken): Client + { + return new Client([ + 'base_uri' => 'https://www.patreon.com/api/oauth2/api/', + 'headers' => [ + 'authorization' => "Bearer {$accessToken}", + ], + ]); + } +} diff --git a/app/Services/Patreon/Resources/Campaign.php b/app/Services/Patreon/Resources/Campaign.php new file mode 100644 index 000000000..07ad18ef4 --- /dev/null +++ b/app/Services/Patreon/Resources/Campaign.php @@ -0,0 +1,32 @@ +id = $id; + $this->name = $name; + $this->rewards = new Collection(); + } +} diff --git a/app/Services/Patreon/Resources/Pledge.php b/app/Services/Patreon/Resources/Pledge.php new file mode 100644 index 000000000..613db790b --- /dev/null +++ b/app/Services/Patreon/Resources/Pledge.php @@ -0,0 +1,39 @@ +id = $id; + $this->amount = $amount; + $this->userId = $userId; + $this->rewardId = $rewardId; + } +} diff --git a/app/Services/Patreon/Resources/Reward.php b/app/Services/Patreon/Resources/Reward.php new file mode 100644 index 000000000..1748bd6a4 --- /dev/null +++ b/app/Services/Patreon/Resources/Reward.php @@ -0,0 +1,26 @@ +id = $id; + $this->amount = $amount; + } +} diff --git a/app/Services/Patreon/Resources/User.php b/app/Services/Patreon/Resources/User.php new file mode 100644 index 000000000..85fd13b9d --- /dev/null +++ b/app/Services/Patreon/Resources/User.php @@ -0,0 +1,31 @@ +id = $id; + $this->name = $name; + $this->avatarUrl = $avatarUrl; + } +} diff --git a/composer.json b/composer.json index 84bbcd6eb..2456f99ca 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,13 @@ "barryvdh/laravel-ide-helper": "^2.4", "doctrine/dbal": "^2.8", "fideloper/proxy": "~4.0", + "guzzlehttp/guzzle": "~6.0", "knplabs/github-api": "^2.8", "laravel/framework": "5.7.*", "laravel/horizon": "^1.3", "laravel/tinker": "~1.0", "myclabs/php-enum": "^1.5", + "patreon/patreon": "^0.3.1", "pda/pheanstalk": "^3.1", "php-http/guzzle6-adapter": "^1.1", "php-http/message": "^1.7", @@ -34,6 +36,7 @@ "spatie/laravel-tail": "^3.0", "spatie/packagist-api": "^1.0", "spatie/schema-org": "^2.0", + "spatie/valuestore": "^1.2", "themsaid/laravel-mail-preview": "^2.0", "zendframework/zend-feed": "^2.9", "zendframework/zend-http": "^2.7" @@ -94,4 +97,4 @@ "dont-discover": [] } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index e8148b1af..055f23155 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e2a40ac66c56f371d6fd2b5ed0ecfdc7", + "content-hash": "4719fbb12566e7780ea693dd87cb9d6d", "packages": [ { "name": "abraham/twitteroauth", @@ -60,6 +60,56 @@ ], "time": "2018-07-04T01:28:41+00:00" }, + { + "name": "art4/json-api-client", + "version": "0.9.1", + "source": { + "type": "git", + "url": "https://github.com/Art4/json-api-client.git", + "reference": "b39aea2567048081c753d2cbc25e509f541248c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Art4/json-api-client/zipball/b39aea2567048081c753d2cbc25e509f541248c9", + "reference": "b39aea2567048081c753d2cbc25e509f541248c9", + "shasum": "" + }, + "require": { + "php": "^5.5 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.4.3 || ^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Art4\\JsonApiClient\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL3" + ], + "authors": [ + { + "name": "Artur Weigandt", + "email": "art4@wlabs.de", + "homepage": "https://wlabs.de" + } + ], + "description": "JSON API client", + "homepage": "https://github.com/Art4/json-api-client", + "keywords": [ + "JSON-API", + "api", + "client", + "json", + "parser", + "reader", + "validator" + ], + "time": "2017-12-21T11:44:10+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v3.2.0", @@ -2555,6 +2605,43 @@ ], "time": "2018-07-02T15:55:56+00:00" }, + { + "name": "patreon/patreon", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/Patreon/patreon-php.git", + "reference": "d4b277e660bd4ae998a8e2da4cf7c50551fa0fd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Patreon/patreon-php/zipball/d4b277e660bd4ae998a8e2da4cf7c50551fa0fd1", + "reference": "d4b277e660bd4ae998a8e2da4cf7c50551fa0fd1", + "shasum": "" + }, + "require": { + "art4/json-api-client": "0.9.*", + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Patreon": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patreon", + "email": "platform-team@patreon.com" + } + ], + "description": "Interact with the Patreon API via OAuth", + "time": "2018-05-18T20:57:28+00:00" + }, { "name": "pda/pheanstalk", "version": "v3.1.0", @@ -4495,6 +4582,59 @@ ], "time": "2018-01-02T20:50:05+00:00" }, + { + "name": "spatie/valuestore", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/valuestore.git", + "reference": "f1f4ca04bc269d8ca353308f107bc99acf584666" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/valuestore/zipball/f1f4ca04bc269d8ca353308f107bc99acf584666", + "reference": "f1f4ca04bc269d8ca353308f107bc99acf584666", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Valuestore\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Jolita Grazyte", + "email": "jolita@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily store some values", + "homepage": "https://github.com/spatie/valuestore", + "keywords": [ + "json", + "spatie", + "valuestore" + ], + "time": "2018-05-15T07:46:16+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.1.2", diff --git a/config/app.php b/config/app.php index 7a7ebaf23..b0ec6214e 100644 --- a/config/app.php +++ b/config/app.php @@ -167,6 +167,7 @@ App\Providers\SessionServiceProvider::class, App\Services\GitHub\GitHubServiceProvider::class, App\Services\Twitter\TwitterServiceProvider::class, + App\Services\Patreon\PatreonServiceProvider::class, ], /* diff --git a/config/services.php b/config/services.php index 904d33cb3..e34f59f80 100644 --- a/config/services.php +++ b/config/services.php @@ -59,6 +59,10 @@ 'instagram' => [ 'token' => env('INSTAGRAM_TOKEN'), - ] + ], + 'patreon' => [ + 'id' => env('PATREON_CLIENT_ID'), + 'secret' => env('PATREON_SECRET'), + ], ]; diff --git a/database/factories/PatreonPledgerFactory.php b/database/factories/PatreonPledgerFactory.php new file mode 100644 index 000000000..612339c21 --- /dev/null +++ b/database/factories/PatreonPledgerFactory.php @@ -0,0 +1,9 @@ +define(\App\Models\PatreonPledger::class, function (Faker\Generator $faker) { + return [ + 'patreon_id' => $faker->numberBetween(0, 100000), + 'name' => $faker->name, + 'avatar_url' => $faker->url, + ]; +}); diff --git a/database/migrations/2018_10_04_165241_create_patreon_pledgers_table.php b/database/migrations/2018_10_04_165241_create_patreon_pledgers_table.php new file mode 100644 index 000000000..278dcf426 --- /dev/null +++ b/database/migrations/2018_10_04_165241_create_patreon_pledgers_table.php @@ -0,0 +1,19 @@ +increments('id'); + $table->integer('patreon_id'); + $table->string('name'); + $table->string('avatar_url'); + $table->timestamps(); + }); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 4aa0f2ab6..b5a06d512 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -12,6 +12,7 @@ public function run() ->call(RepositoriesSeeder::class) ->call(UserSeeder::class) ->call(ContributorSeeder::class) + ->call(PatreonPledgersSeeder::class) ->call(PostcardsSeeder::class) ->call(InstagramPhotosSeeder::class); } diff --git a/database/seeds/PatreonPledgersSeeder.php b/database/seeds/PatreonPledgersSeeder.php new file mode 100644 index 000000000..8b0133bef --- /dev/null +++ b/database/seeds/PatreonPledgersSeeder.php @@ -0,0 +1,12 @@ +create(); + } +} diff --git a/resources/views/pages/open-source/partials/patreon.blade.php b/resources/views/pages/open-source/partials/patreon.blade.php new file mode 100644 index 000000000..e72686ff1 --- /dev/null +++ b/resources/views/pages/open-source/partials/patreon.blade.php @@ -0,0 +1,13 @@ +
+
+ +
+
+

+ {{ $patreonPledger->name }} +

+ +
+
diff --git a/resources/views/pages/open-source/partials/support.blade.php b/resources/views/pages/open-source/partials/support.blade.php index 561e4f8c5..9c77198e4 100644 --- a/resources/views/pages/open-source/partials/support.blade.php +++ b/resources/views/pages/open-source/partials/support.blade.php @@ -20,4 +20,24 @@ + {{-- if # patreons > 0 --}} +
+

+ Following patreons have helped us out in a substantial way.
+ Time to pay our respect! +

+
+
+ {{-- start loop for patreons --}} +
+ @include('pages.open-source.partials.patreon') +
+ {{-- end loop for patreons --}} +
+
+ +
+ {{-- end if --}}