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 @@
+ 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); +});