Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
4 changed files with 429 additions and 21 deletions
Showing only changes of commit 34b78e6e54 - Show all commits

View file

@ -7,6 +7,7 @@
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Services\Auth\LemmyAuthService; use App\Services\Auth\LemmyAuthService;
use App\Services\Log\LogSaver;
use Exception; use Exception;
class LemmyPublisher class LemmyPublisher
@ -15,10 +16,13 @@ class LemmyPublisher
private PlatformAccount $account; private PlatformAccount $account;
private ThumbnailUploader $thumbnailUploader;
public function __construct(PlatformAccount $account) public function __construct(PlatformAccount $account)
{ {
$this->api = new LemmyApiService($account->instance_url); $this->api = new LemmyApiService($account->instance_url);
$this->account = $account; $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; $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( return $this->api->createPost(
$token, $token,
$extractedData['title'] ?? 'Untitled', $extractedData['title'] ?? 'Untitled',
$extractedData['description'] ?? '', $extractedData['description'] ?? '',
$channel->channel_id, $channel->channel_id,
$article->url, $article->url,
$extractedData['thumbnail'] ?? null, $hosted,
$languageId $languageId
); );
} }

View file

@ -0,0 +1,125 @@
<?php
namespace App\Modules\Lemmy\Services;
use App\Modules\Lemmy\LemmyRequest;
use Illuminate\Support\Facades\Http;
use Throwable;
class ThumbnailUploader
{
private const MAX_WIDTH = 600;
// A 4000x2256 JPEG decodes to ~27MB in GD; the worker runs with memory_limit=128M.
private const MAX_SOURCE_BYTES = 10_485_760;
private const MAX_SOURCE_PIXELS = 50_000_000;
private const JPEG_QUALITY = 82;
public function __construct(private string $instance) {}
/**
* Returns an instance-hosted URL for a downscaled copy, or null if anything fails.
*/
public function upload(?string $sourceUrl, string $token): ?string
{
if ($sourceUrl === null || $sourceUrl === '') {
return null;
}
try {
$source = $this->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);
}
}

View file

@ -9,7 +9,9 @@
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Modules\Lemmy\Services\ThumbnailUploader;
use App\Services\Auth\LemmyAuthService; use App\Services\Auth\LemmyAuthService;
use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
@ -25,6 +27,29 @@ protected function tearDown(): void
parent::tearDown(); 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 public function test_constructor_initializes_api_service(): void
{ {
$account = PlatformAccount::factory()->make([ $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 // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $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 // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $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 // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $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 // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage('API Error'); $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 // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $result = $publisher->publishToChannel($article, $extractedData, $channel);
$this->assertEquals(['success' => true], $result); $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));
}
} }

View file

@ -0,0 +1,168 @@
<?php
namespace Tests\Unit\Modules\Lemmy\Services;
use App\Modules\Lemmy\Services\ThumbnailUploader;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ThumbnailUploaderTest extends TestCase
{
/**
* @param int<1, max> $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('<html>not an image</html>', ['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 '';
}
}