126 lines
3.1 KiB
PHP
126 lines
3.1 KiB
PHP
|
|
<?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);
|
||
|
|
}
|
||
|
|
}
|