trypost/app/Models/Traits/HasMedia.php
Paulo Castellano 770266d36b refactor: centralize media size limits in config + drop Document type
Three things in one move:

1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
   The MediaType enum now reads from there:

       MediaType::Image->maxSizeInMb()    // 10  (env: MEDIA_IMAGE_MAX_SIZE_MB)
       MediaType::Video->maxSizeInMb()    // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)

   Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
   don't have to multiply themselves. StoreAssetRequest now uses
   MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
   from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
   validation moved to the new StoreChunkedAssetRequest FormRequest
   (also reads from the enum). MediaAttacher uses
   MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
   and enforces the per-type cap after MIME resolution.

2. Drop MediaType::Document. We never accepted PDFs anywhere — the
   StoreAssetRequest mimes list excluded them, the storeChunked
   extension regex excluded them, MediaAttacher excluded them. The only
   places that referenced Document were:
     - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
       but unreachable)
     - HasMedia::getMediaType fallback when MIME wasn't image/video/*
   Both now cleaned up. HasMedia::getMediaType throws
   InvalidArgumentException for unsupported MIMEs instead of silently
   returning a fake 'document' type. Platform::LinkedIn now matches
   every other social platform: [Image, Video].

3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
   type loop that MediaAttacher used to roll. Returns null for
   unsupported MIMEs (caller decides how to react).

Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
  sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
  asserts the throw on unsupported MIMEs. The 'add media from path'
  test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
  (FormRequest unprocessable) for malformed Content-Range headers,
  matching the new validation layer.
2026-05-04 14:54:47 -03:00

235 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models\Traits;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\ImageManager;
trait HasMedia
{
/**
* Media collections configuration.
* 'single' = only one media per collection (replaces existing)
* 'multiple' = unlimited media per collection
*/
protected static array $mediaCollections = [
Workspace::class => [
'logo' => 'single',
'assets' => 'multiple',
],
User::class => [
'avatar' => 'single',
],
];
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'mediable')->orderBy('order');
}
public function getMedia(string $collection = 'default'): MorphMany
{
return $this->media()->where('collection', $collection);
}
public function getFirstMedia(string $collection = 'default'): ?Media
{
return $this->getMedia($collection)->first();
}
public function getFirstMediaUrl(string $collection = 'default', ?string $default = null): ?string
{
$media = $this->getFirstMedia($collection);
return $media?->url ?? $default;
}
/**
* Generate a DiceBear fallback URL using initials.
*/
public function getFallbackAvatarUrl(string $seed): string
{
return 'https://api.dicebear.com/9.x/initials/svg?backgroundColor=777777&fontFamily=Verdana&fontSize=40&seed='.urlencode($seed);
}
/**
* Add media to a collection.
* If the collection is configured as 'single', it will clear existing media first.
*/
public function addMedia(UploadedFile $file, string $collection = 'default', array $meta = [], ?string $groupId = null): Media
{
if ($this->isSingleMediaCollection($collection)) {
$this->clearMediaCollection($collection);
}
$mimeType = $file->getMimeType();
$type = $this->getMediaType($mimeType);
// Normalize non-JPEG still images to JPEG q100 for universal platform compatibility.
// GIF is preserved (animation kept for X/Bluesky/Mastodon).
[$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat(
$file->getPathname(),
$mimeType,
$type,
$file->getClientOriginalExtension(),
);
$filename = Str::uuid().'.'.$normalizedExt;
$path = 'medias/'.$filename;
Storage::put($path, $normalizedBytes);
return $this->media()->create([
'group_id' => $groupId ?? Str::uuid()->toString(),
'collection' => $collection,
'type' => $type,
'path' => $path,
'original_filename' => $file->getClientOriginalName(),
'mime_type' => $normalizedMime,
'size' => strlen($normalizedBytes),
'order' => 0,
'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta),
]);
}
/**
* Add media from a file path (used for chunked uploads).
*/
public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media
{
if ($this->isSingleMediaCollection($collection)) {
$this->clearMediaCollection($collection);
}
$mimeType = mime_content_type($filePath);
$type = $this->getMediaType($mimeType);
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
[$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat(
$filePath,
$mimeType,
$type,
$extension,
);
$filename = Str::uuid().'.'.$normalizedExt;
$storagePath = 'medias/'.$filename;
Storage::put($storagePath, $normalizedBytes);
return $this->media()->create([
'group_id' => $groupId ?? Str::uuid()->toString(),
'collection' => $collection,
'type' => $type,
'path' => $storagePath,
'original_filename' => $originalFilename,
'mime_type' => $normalizedMime,
'size' => strlen($normalizedBytes),
'order' => 0,
'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta),
]);
}
public function clearMediaCollection(string $collection = 'default'): void
{
$this->getMedia($collection)->each(fn (Media $media) => $media->delete());
}
public function isSingleMediaCollection(string $collection): bool
{
$modelClass = static::class;
$config = self::$mediaCollections[$modelClass][$collection] ?? 'multiple';
return $config === 'single';
}
private function getMediaType(string $mimeType): string
{
if (str_starts_with($mimeType, 'image/')) {
return 'image';
}
if (str_starts_with($mimeType, 'video/')) {
return 'video';
}
throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}");
}
private function getMediaMeta(UploadedFile $file, string $type): array
{
$meta = [];
if ($type === 'image') {
$imageInfo = @getimagesize($file->getPathname());
if ($imageInfo) {
$meta['width'] = $imageInfo[0];
$meta['height'] = $imageInfo[1];
}
}
return $meta;
}
/**
* Extract width/height from raw image bytes (used after format normalization
* when we no longer have the original file path).
*/
private function getMediaMetaFromBytes(string $bytes, string $type, array $clientMeta = []): array
{
$meta = [];
if ($type === 'image') {
$imageInfo = @getimagesizefromstring($bytes);
if ($imageInfo) {
$meta['width'] = $imageInfo[0];
$meta['height'] = $imageInfo[1];
}
}
return $meta;
}
/**
* Convert PNG/WebP/HEIC/AVIF to JPEG at q100 (keeps dimensions). GIF and
* JPEG are returned untouched. Non-image types are passed through.
*
* @return array{0: string, 1: string, 2: string} [bytes, mime_type, extension]
*/
private function normalizeImageFormat(string $filePath, string $mimeType, string $type, string $originalExtension): array
{
if ($type !== 'image') {
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
// Formats that publish safely everywhere (JPEG is universal, GIF needed for X/Bluesky/Mastodon).
if (in_array($mimeType, ['image/jpeg', 'image/jpg', 'image/gif'], true)) {
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
try {
$manager = new ImageManager(new Driver);
$encoded = (string) $manager->decodePath($filePath)->encode(new JpegEncoder(quality: 100));
return [$encoded, 'image/jpeg', 'jpg'];
} catch (\Throwable $e) {
Log::warning('HasMedia: image normalization failed, storing original', [
'mime' => $mimeType,
'error' => $e->getMessage(),
]);
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
}
}