Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
137 lines
5.1 KiB
PHP
137 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Uploads;
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Intervention\Image\ImageManager as ImageManager;
|
|
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
|
|
use RuntimeException;
|
|
|
|
final class UploadDerivativesService
|
|
{
|
|
private bool $imageAvailable = false;
|
|
private ?ImageManager $manager = null;
|
|
|
|
public function __construct(private readonly UploadStorageService $storage)
|
|
{
|
|
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
|
|
try {
|
|
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
|
$this->imageAvailable = true;
|
|
} catch (\Throwable $e) {
|
|
logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage());
|
|
$this->imageAvailable = false;
|
|
$this->manager = null;
|
|
}
|
|
}
|
|
|
|
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): string
|
|
{
|
|
$this->assertImageAvailable();
|
|
// Preserve original file extension and store with filename = <hash>.<ext>
|
|
$dir = $this->storage->ensureHashDirectory('original', $hash);
|
|
|
|
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
|
|
$target = $dir . DIRECTORY_SEPARATOR . $hash . ($origExt !== '' ? '.' . $origExt : '');
|
|
|
|
// Try a direct copy first (works for images and archives). If that fails,
|
|
// fall back to re-encoding image to webp as a last resort.
|
|
try {
|
|
if (! File::copy($sourcePath, $target)) {
|
|
throw new \RuntimeException('Copy failed');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Fallback: encode to webp
|
|
$quality = (int) config('uploads.quality', 85);
|
|
/** @var InterventionImageInterface $img */
|
|
$img = $this->manager->read($sourcePath);
|
|
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
|
$encoded = (string) $img->encode($encoder);
|
|
$target = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
|
|
File::put($target, $encoded);
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
|
|
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
|
|
{
|
|
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
|
|
if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) {
|
|
return $fromClientName;
|
|
}
|
|
|
|
$fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
|
if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) {
|
|
return $fromSource;
|
|
}
|
|
|
|
$mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : '';
|
|
return $this->extensionFromMime($mime);
|
|
}
|
|
|
|
private function extensionFromMime(string $mime): string
|
|
{
|
|
return match (strtolower($mime)) {
|
|
'image/jpeg', 'image/jpg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/webp' => 'webp',
|
|
'image/gif' => 'gif',
|
|
'image/bmp' => 'bmp',
|
|
'image/tiff' => 'tif',
|
|
'application/zip', 'application/x-zip-compressed' => 'zip',
|
|
'application/x-rar-compressed', 'application/vnd.rar' => 'rar',
|
|
'application/x-7z-compressed' => '7z',
|
|
'application/x-tar' => 'tar',
|
|
'application/gzip', 'application/x-gzip' => 'gz',
|
|
default => 'bin',
|
|
};
|
|
}
|
|
|
|
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
|
{
|
|
$this->assertImageAvailable();
|
|
$quality = (int) config('uploads.quality', 85);
|
|
$variants = (array) config('uploads.derivatives', []);
|
|
$written = [];
|
|
|
|
foreach ($variants as $variant => $options) {
|
|
$variant = (string) $variant;
|
|
$dir = $this->storage->ensureHashDirectory($variant, $hash);
|
|
// store derivative filename as <hash>.webp per variant directory
|
|
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
|
|
|
|
/** @var InterventionImageInterface $img */
|
|
$img = $this->manager->read($sourcePath);
|
|
|
|
if (isset($options['size'])) {
|
|
$size = (int) $options['size'];
|
|
$out = $img->cover($size, $size);
|
|
} else {
|
|
$max = (int) ($options['max'] ?? 0);
|
|
if ($max <= 0) {
|
|
$max = 2560;
|
|
}
|
|
|
|
$out = $img->scaleDown($max, $max);
|
|
}
|
|
|
|
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
|
$encoded = (string) $out->encode($encoder);
|
|
File::put($path, $encoded);
|
|
$written[$variant] = $path;
|
|
}
|
|
|
|
return $written;
|
|
}
|
|
|
|
private function assertImageAvailable(): void
|
|
{
|
|
if (! $this->imageAvailable) {
|
|
throw new RuntimeException('Intervention Image is not available.');
|
|
}
|
|
}
|
|
}
|