diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index 11d7e300..cce698d3 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -7,6 +7,7 @@ use App\Models\PlatformAccount; use App\Models\PlatformChannel; use App\Services\Auth\LemmyAuthService; +use App\Services\Log\LogSaver; use Exception; class LemmyPublisher @@ -15,10 +16,13 @@ class LemmyPublisher private PlatformAccount $account; + private ThumbnailUploader $thumbnailUploader; + public function __construct(PlatformAccount $account) { $this->api = new LemmyApiService($account->instance_url); $this->account = $account; + $this->thumbnailUploader = new ThumbnailUploader($account->instance_url); } /** @@ -54,13 +58,25 @@ private function createPost(string $token, array $extractedData, PlatformChannel { $languageId = $extractedData['language_id'] ?? null; + $thumbnail = $extractedData['thumbnail'] ?? null; + $thumbnail = is_string($thumbnail) && $thumbnail !== '' ? $thumbnail : null; + + $hosted = $this->thumbnailUploader->upload($thumbnail, $token); + + if ($thumbnail !== null && $hosted === null) { + resolve(LogSaver::class)->warning('Thumbnail upload failed; publishing without one', $channel, [ + 'article_id' => $article->id, + 'source' => $thumbnail, + ]); + } + return $this->api->createPost( $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', $channel->channel_id, $article->url, - $extractedData['thumbnail'] ?? null, + $hosted, $languageId ); } diff --git a/app/Modules/Lemmy/Services/ThumbnailUploader.php b/app/Modules/Lemmy/Services/ThumbnailUploader.php new file mode 100644 index 00000000..c6209eb9 --- /dev/null +++ b/app/Modules/Lemmy/Services/ThumbnailUploader.php @@ -0,0 +1,125 @@ +download($sourceUrl); + + if ($source === null) { + return null; + } + + $resized = $this->resize($source); + + if ($resized === null) { + return null; + } + + return $this->store($resized, $token); + } catch (Throwable) { + return null; + } + } + + private function download(string $url): ?string + { + $response = Http::timeout(30)->get($url); + + if (! $response->successful()) { + return null; + } + + $body = $response->body(); + + return strlen($body) > self::MAX_SOURCE_BYTES ? null : $body; + } + + private function resize(string $source): ?string + { + $info = @getimagesizefromstring($source); + + if ($info === false) { + return null; + } + + [$width, $height] = $info; + + if ($width < 1 || $height < 1 || $width * $height > self::MAX_SOURCE_PIXELS) { + return null; + } + + if ($width <= self::MAX_WIDTH) { + return $source; + } + + $image = @imagecreatefromstring($source); + + if ($image === false) { + return null; + } + + $targetHeight = (int) max(1, round($height * (self::MAX_WIDTH / $width))); + $resized = imagescale($image, self::MAX_WIDTH, $targetHeight); + imagedestroy($image); + + if ($resized === false) { + return null; + } + + ob_start(); + + try { + imagejpeg($resized, null, self::JPEG_QUALITY); + } finally { + $bytes = (string) ob_get_clean(); + imagedestroy($resized); + } + + return $bytes === '' ? null : $bytes; + } + + private function store(string $bytes, string $token): ?string + { + $response = (new LemmyRequest($this->instance, $token)) + ->postMultipart('pictrs/image', 'images[]', $bytes, 'thumbnail.jpg'); + + if (! $response->successful()) { + return null; + } + + $file = $response->json('files.0.file'); + + if (! is_string($file) || $file === '') { + return null; + } + + // $instance is a full scheme-qualified URL — platform_accounts.instance_url is validated as a URL. + return sprintf('%s/pictrs/image/%s', rtrim($this->instance, '/'), $file); + } +} diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php index 1781ffc0..3e53f211 100644 --- a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php +++ b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php @@ -9,7 +9,9 @@ use App\Models\PlatformChannel; use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyPublisher; +use App\Modules\Lemmy\Services\ThumbnailUploader; use App\Services\Auth\LemmyAuthService; +use App\Services\Log\LogSaver; use Exception; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; @@ -25,6 +27,29 @@ protected function tearDown(): void parent::tearDown(); } + /** + * Swaps in a mocked API and an uploader that echoes its input back, so existing + * expectations can keep asserting on the thumbnail they passed in. + */ + private function injectMocks(LemmyPublisher $publisher, LemmyApiService $api, ?ThumbnailUploader $uploader = null): void + { + if (! $uploader instanceof ThumbnailUploader) { + $passthrough = Mockery::mock(ThumbnailUploader::class); + $passthrough->shouldReceive('upload')->andReturnUsing(fn (?string $url): ?string => $url); + $uploader = $passthrough; + } + + $reflection = new \ReflectionClass($publisher); + + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $api); + + $uploaderProperty = $reflection->getProperty('thumbnailUploader'); + $uploaderProperty->setAccessible(true); + $uploaderProperty->setValue($publisher, $uploader); + } + public function test_constructor_initializes_api_service(): void { $account = PlatformAccount::factory()->make([ @@ -92,10 +117,7 @@ public function test_publish_to_channel_with_all_data(): void // Create publisher and inject mocked API using reflection $publisher = new LemmyPublisher($account); - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); + $this->injectMocks($publisher, $apiMock); $result = $publisher->publishToChannel($article, $extractedData, $channel); @@ -145,10 +167,7 @@ public function test_publish_to_channel_with_minimal_data(): void // Create publisher and inject mocked API using reflection $publisher = new LemmyPublisher($account); - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); + $this->injectMocks($publisher, $apiMock); $result = $publisher->publishToChannel($article, $extractedData, $channel); @@ -201,10 +220,7 @@ public function test_publish_to_channel_without_thumbnail(): void // Create publisher and inject mocked API using reflection $publisher = new LemmyPublisher($account); - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); + $this->injectMocks($publisher, $apiMock); $result = $publisher->publishToChannel($article, $extractedData, $channel); @@ -273,10 +289,7 @@ public function test_publish_to_channel_throws_api_exception(): void // Create publisher and inject mocked API using reflection $publisher = new LemmyPublisher($account); - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); + $this->injectMocks($publisher, $apiMock); $this->expectException(Exception::class); $this->expectExceptionMessage('API Error'); @@ -327,13 +340,99 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create // Create publisher and inject mocked API using reflection $publisher = new LemmyPublisher($account); - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); + $this->injectMocks($publisher, $apiMock); $result = $publisher->publishToChannel($article, $extractedData, $channel); $this->assertEquals(['success' => true], $result); } + + public function test_it_passes_the_uploaded_thumbnail_url_to_create_post(): void + { + $account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']); + $article = Article::factory()->make(['url' => 'https://example.com/article']); + $channel = PlatformChannel::factory()->make(['channel_id' => 7]); + + $authMock = Mockery::mock(LemmyAuthService::class); + $authMock->shouldReceive('getToken')->andReturn('tok'); + $this->app->instance(LemmyAuthService::class, $authMock); + + $apiMock = Mockery::mock(LemmyApiService::class); + $apiMock->shouldReceive('createPost') + ->once() + ->with('tok', 'T', '', 7, 'https://example.com/article', 'https://lemmy.world/pictrs/image/x.jpg', null) + ->andReturn(['ok' => true]); + + $uploader = Mockery::mock(ThumbnailUploader::class); + $uploader->shouldReceive('upload') + ->once() + ->with('https://cdn.example.com/big.jpg', 'tok') + ->andReturn('https://lemmy.world/pictrs/image/x.jpg'); + + $publisher = new LemmyPublisher($account); + $this->injectMocks($publisher, $apiMock, $uploader); + + $this->assertSame( + ['ok' => true], + $publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel) + ); + } + + public function test_it_logs_a_warning_when_the_thumbnail_upload_fails(): void + { + $account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']); + $article = Article::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + $authMock = Mockery::mock(LemmyAuthService::class); + $authMock->shouldReceive('getToken')->andReturn('tok'); + $this->app->instance(LemmyAuthService::class, $authMock); + + $logMock = Mockery::mock(LogSaver::class); + $logMock->shouldReceive('warning') + ->once() + ->withArgs(fn (string $message, ?PlatformChannel $c, array $context): bool => str_contains($message, 'Thumbnail upload failed') + && $context['source'] === 'https://cdn.example.com/big.jpg'); + $this->app->instance(LogSaver::class, $logMock); + + $apiMock = Mockery::mock(LemmyApiService::class); + $apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]); + + $uploader = Mockery::mock(ThumbnailUploader::class); + $uploader->shouldReceive('upload')->once()->andReturn(null); + + $publisher = new LemmyPublisher($account); + $this->injectMocks($publisher, $apiMock, $uploader); + + $this->assertSame( + ['ok' => true], + $publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel) + ); + } + + public function test_it_does_not_log_when_there_was_no_thumbnail_to_upload(): void + { + $account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']); + $article = Article::factory()->make(['url' => 'https://example.com/article']); + $channel = PlatformChannel::factory()->make(['channel_id' => 7]); + + $authMock = Mockery::mock(LemmyAuthService::class); + $authMock->shouldReceive('getToken')->andReturn('tok'); + $this->app->instance(LemmyAuthService::class, $authMock); + + $logMock = Mockery::mock(LogSaver::class); + $logMock->shouldNotReceive('warning'); + $this->app->instance(LogSaver::class, $logMock); + + $apiMock = Mockery::mock(LemmyApiService::class); + $apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]); + + $uploader = Mockery::mock(ThumbnailUploader::class); + $uploader->shouldReceive('upload')->once()->with(null, 'tok')->andReturn(null); + + $publisher = new LemmyPublisher($account); + $this->injectMocks($publisher, $apiMock, $uploader); + + $this->assertSame(['ok' => true], $publisher->publishToChannel($article, ['title' => 'T'], $channel)); + } } diff --git a/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php b/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php new file mode 100644 index 00000000..10a332a5 --- /dev/null +++ b/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php @@ -0,0 +1,168 @@ + $width + * @param int<1, max> $height + */ + private function jpeg(int $width, int $height): string + { + $image = imagecreatetruecolor($width, $height); + $colour = imagecolorallocate($image, 120, 90, 60); + imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $colour === false ? 0 : $colour); + + ob_start(); + imagejpeg($image, null, 90); + $bytes = (string) ob_get_clean(); + imagedestroy($image); + + return $bytes; + } + + private function uploader(): ThumbnailUploader + { + return new ThumbnailUploader('https://lemmy.world'); + } + + private function fake(string $sourceBody, mixed $uploadResponse, int $uploadStatus = 200): void + { + Http::fake([ + 'https://cdn.example.com/*' => Http::response($sourceBody, 200, ['Content-Type' => 'image/jpeg']), + 'https://lemmy.world/pictrs/image' => Http::response($uploadResponse, $uploadStatus), + ]); + } + + public function test_it_returns_an_instance_hosted_url(): void + { + $this->fake($this->jpeg(1200, 800), ['files' => [['file' => 'abc123.jpg', 'delete_token' => 'tok']]]); + + $url = $this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'); + + $this->assertSame('https://lemmy.world/pictrs/image/abc123.jpg', $url); + } + + public function test_it_downscales_a_wide_image_before_upload(): void + { + $source = $this->jpeg(2000, 1000); + $this->fake($source, ['files' => [['file' => 'abc123.jpg']]]); + + $this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'); + + Http::assertSent(function (Request $request) use ($source): bool { + if ($request->url() !== 'https://lemmy.world/pictrs/image') { + return false; + } + + $uploaded = $this->multipartFileContents($request); + $size = getimagesizefromstring($uploaded); + + return $size !== false + && $size[0] === 600 + && $size[1] === 300 + && $uploaded !== $source + && strlen($uploaded) < strlen($source); + }); + } + + public function test_it_uploads_a_small_image_untouched(): void + { + $source = $this->jpeg(320, 240); + $this->fake($source, ['files' => [['file' => 'abc123.jpg']]]); + + $this->uploader()->upload('https://cdn.example.com/small.jpg', 'token'); + + Http::assertSent(function (Request $request) use ($source): bool { + return $request->url() !== 'https://lemmy.world/pictrs/image' + || $this->multipartFileContents($request) === $source; + }); + } + + public function test_it_sends_the_token_as_a_bearer_header(): void + { + $this->fake($this->jpeg(800, 600), ['files' => [['file' => 'abc123.jpg']]]); + + $this->uploader()->upload('https://cdn.example.com/big.jpg', 'secret-token'); + + Http::assertSent(fn (Request $request): bool => $request->url() !== 'https://lemmy.world/pictrs/image' + || $request->header('Authorization')[0] === 'Bearer secret-token'); + } + + public function test_it_returns_null_for_a_null_source(): void + { + Http::fake(); + + $this->assertNull($this->uploader()->upload(null, 'token')); + + Http::assertNothingSent(); + } + + public function test_it_returns_null_for_an_empty_source(): void + { + Http::fake(); + + $this->assertNull($this->uploader()->upload('', 'token')); + + Http::assertNothingSent(); + } + + public function test_it_returns_null_when_the_download_fails(): void + { + Http::fake(['https://cdn.example.com/*' => Http::response('', 404)]); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/gone.jpg', 'token')); + } + + public function test_it_returns_null_when_the_source_is_not_an_image(): void + { + $this->fake('not an image', ['files' => [['file' => 'abc123.jpg']]]); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/page.html', 'token')); + } + + public function test_it_returns_null_when_the_upload_fails(): void + { + $this->fake($this->jpeg(1200, 800), ['error' => 'nope'], 500); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token')); + } + + public function test_it_returns_null_when_the_upload_response_has_no_file(): void + { + $this->fake($this->jpeg(1200, 800), ['files' => []]); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token')); + } + + public function test_it_returns_null_when_the_source_exceeds_the_size_cap(): void + { + $this->fake(str_repeat('x', 10_485_761), ['files' => [['file' => 'abc123.jpg']]]); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/huge.jpg', 'token')); + } + + public function test_it_returns_null_when_the_download_throws(): void + { + Http::fake(fn () => throw new \RuntimeException('connection refused')); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token')); + } + + private function multipartFileContents(Request $request): string + { + foreach ($request->data() as $part) { + if (($part['name'] ?? null) === 'images[]') { + return (string) $part['contents']; + } + } + + return ''; + } +}