diff --git a/.env.example b/.env.example index 0581c42f0..b9bedb501 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,9 @@ TELEGRAM_CHANNEL= FATHOM_SITE_ID= FATHOM_TOKEN= + +UNSPLASH_ACCESS_KEY= + LOG_STACK=single SESSION_ENCRYPT=false SESSION_PATH=/ diff --git a/README.md b/README.md index 75f4d77f7..ce2d15213 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,20 @@ FATHOM_SITE_ID= FATHOM_TOKEN= ``` +### Unsplash (optional) + +To make sure article and user header images get synced into the database we'll need to setup an access key from [Unsplash](https://unsplash.com/developers). Please note that your Unsplash app requires production access. + +``` +UNSPLASH_ACCESS_KEY= +``` + +After that you can add an Unsplash photo ID to any article row in the `hero_image_id` column and run the sync command to fetch the image url and author data: + +```bash +php artisan lio:sync-article-images +``` + ## Commands Command | Description diff --git a/app/Console/Commands/SyncArticleImages.php b/app/Console/Commands/SyncArticleImages.php new file mode 100644 index 000000000..8d57ad150 --- /dev/null +++ b/app/Console/Commands/SyncArticleImages.php @@ -0,0 +1,65 @@ +error('Unsplash access key must be configured'); + + return; + } + + Article::unsyncedImages()->chunk(100, function ($articles) { + $articles->each(function ($article) { + $imageData = $this->fetchUnsplashImageDataFromId($article->hero_image_id); + + if (! is_null($imageData)) { + $article->hero_image_url = $imageData['image_url']; + $article->hero_image_author_name = $imageData['author_name']; + $article->hero_image_author_url = $imageData['author_url']; + $article->save(); + } + }); + }); + } + + protected function fetchUnsplashImageDataFromId(string $imageId): ?array + { + $response = Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get("https://api.unsplash.com/photos/{$imageId}"); + + if ($response->failed()) { + logger()->error('Failed to get raw image url from unsplash for', [ + 'imageId' => $imageId, + 'response' => $response->json(), + ]); + + return null; + } + + $response = $response->json(); + + // Trigger as download... + Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get($response['links']['download_location']); + + return [ + 'image_url' => $response['urls']['raw'], + 'author_name' => $response['user']['name'], + 'author_url' => $response['user']['links']['html'] + ]; + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index 9fa33d422..485274134 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -44,7 +44,10 @@ final class Article extends Model implements Feedable 'body', 'original_url', 'slug', - 'hero_image', + 'hero_image_id', + 'hero_image_url', + 'hero_image_author_name', + 'hero_image_author_url', 'is_pinned', 'view_count', 'tweet_id', @@ -100,13 +103,19 @@ public function excerpt(int $limit = 100): string public function hasHeroImage(): bool { - return $this->hero_image !== null; + return $this->hero_image_url !== null; + } + + public function hasHeroImageAuthor(): bool + { + return $this->hero_image_author_name !== null && + $this->hero_image_author_url !== null; } public function heroImage($width = 400, $height = 300): string { - if ($this->hero_image) { - return "https://source.unsplash.com/{$this->hero_image}/{$width}x{$height}"; + if ($this->hasHeroImage()) { + return "{$this->hero_image_url}&fit=clip&w={$width}&h={$height}&utm_source=Laravel.io&utm_medium=referral"; } return asset('images/default-background.svg'); @@ -309,6 +318,12 @@ public function scopeTrending(Builder $query): Builder ->orderBy('submitted_at', 'desc'); } + public function scopeUnsyncedImages(Builder $query): Builder + { + return $query->whereNotNull('hero_image_id') + ->whereNull('hero_image_url'); + } + public function shouldBeSearchable() { return $this->isPublished(); diff --git a/config/services.php b/config/services.php index 1105eb382..2a575db66 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,8 @@ 'token' => env('FATHOM_TOKEN'), ], + 'unsplash' => [ + 'access_key' => env('UNSPLASH_ACCESS_KEY'), + ], + ]; diff --git a/database/migrations/2024_09_27_095949_add_hero_image_additional_columns_to_articles.php b/database/migrations/2024_09_27_095949_add_hero_image_additional_columns_to_articles.php new file mode 100644 index 000000000..fb7f1174e --- /dev/null +++ b/database/migrations/2024_09_27_095949_add_hero_image_additional_columns_to_articles.php @@ -0,0 +1,26 @@ +after('hero_image', function () use ($table) { + $table->string('hero_image_url')->nullable(); + $table->string('hero_image_author_name')->nullable(); + $table->string('hero_image_author_url')->nullable(); + }); + }); + + Schema::table('articles', function (Blueprint $table) { + $table->renameColumn('hero_image', 'hero_image_id'); + }); + } +}; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 48e5d4632..5c3fe18a4 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -44,7 +44,7 @@ public function run(): void [ 'submitted_at' => now(), 'approved_at' => now(), - 'hero_image' => 'sxiSod0tyYQ', + 'hero_image_id' => 'sxiSod0tyYQ', ], ['submitted_at' => now(), 'approved_at' => now()], ['submitted_at' => now()], diff --git a/phpunit.xml b/phpunit.xml index decef64a6..f9d9d51db 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + @@ -32,6 +33,6 @@ - + diff --git a/resources/views/articles/show.blade.php b/resources/views/articles/show.blade.php index 26daea2ca..b473b2936 100644 --- a/resources/views/articles/show.blade.php +++ b/resources/views/articles/show.blade.php @@ -85,6 +85,12 @@ class="w-full bg-center {{ $article->hasHeroImage() ? 'bg-cover' : '' }} bg-gray + + @if ($article->hasHeroImageAuthor()) +

+ Photo by {{ $article->hero_image_author_name }} on Unsplash +

+ @endif diff --git a/tests/Integration/Commands/SyncArticleImagesTest.php b/tests/Integration/Commands/SyncArticleImagesTest.php new file mode 100644 index 000000000..a545d8d00 --- /dev/null +++ b/tests/Integration/Commands/SyncArticleImagesTest.php @@ -0,0 +1,60 @@ + [ + 'raw' => 'https://images.unsplash.com/photo-1584824486509-112e4181ff6b?ixid=M3w2NTgwOTl8MHwxfGFsbHx8fHx8fHx8fDE3Mjc2ODMzMzZ8&ixlib=rb-4.0.3' + ], + 'user' => [ + 'name' => 'Erik Mclean', + 'links' => [ + 'html' => 'https://unsplash.com/@introspectivedsgn', + ] + ], + ]; + }); + + $article = Article::factory()->create([ + 'hero_image_id' => 'sxiSod0tyYQ', + 'submitted_at' => now(), + 'approved_at' => now(), + ]); + + (new SyncArticleImages)->handle(); + + $article->refresh(); + + expect($article->heroImage())->toContain('https://images.unsplash.com/photo-1584824486509-112e4181ff6b'); + expect($article->hero_image_author_name)->toBe('Erik Mclean'); + expect($article->hero_image_author_url)->toBe('https://unsplash.com/@introspectivedsgn'); +}); + +test('hero image url and author information is not updated for published articles with no hero image', function () { + Config::set('services.unsplash.access_key', 'test'); + + $article = Article::factory()->create([ + 'submitted_at' => now(), + 'approved_at' => now(), + ]); + + (new SyncArticleImages)->handle(); + + $article->refresh(); + + expect($article->hero_image_url)->toBe(null); + expect($article->hero_image_author_name)->toBe(null); + expect($article->hero_image_author_url)->toBe(null); +});