Files
landing-api/app/Models/Media.php
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:55:27 +02:00

245 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Jobs\ProcessMedia;
use Database\Factories\MediaFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* An uploaded file and everything derived from it.
*
* Identified by (disk, path) rather than by an id chosen here, so the record
* follows the file: uploading the same image twice, or pointing two models at
* one upload, converges on a single row and a single set of derived versions.
*
* Nothing here is authored. If the table were dropped it could be rebuilt from
* the files with `php artisan media:process --all`.
*/
#[Fillable(['disk', 'path', 'mime', 'size', 'width', 'height', 'placeholder', 'variants', 'processed_at', 'error'])]
class Media extends Model
{
/** @use HasFactory<MediaFactory> */
use HasFactory;
protected $table = 'media';
protected function casts(): array
{
return [
'size' => 'integer',
'width' => 'integer',
'height' => 'integer',
'variants' => 'array',
'processed_at' => 'datetime',
];
}
/**
* The row for a stored path, creating and queueing it the first time.
*
* Returns null for a blank path so callers can hand it whatever an
* optional image field holds without checking first.
*/
public static function for(?string $path, ?string $disk = null): ?self
{
if (blank($path)) {
return null;
}
$disk ??= (string) config('media.disk', 'public');
$media = static::query()->firstOrCreate(
['disk' => $disk, 'path' => ltrim($path, '/')],
);
if ($media->wasRecentlyCreated) {
ProcessMedia::dispatch($media);
}
return $media;
}
/**
* Absolute URL. The frontend runs on another host, so a path only this
* application can resolve would be no use to it.
*/
public function url(): string
{
return static::absolute(Storage::disk($this->disk)->url($this->path));
}
public function exists(): bool
{
return Storage::disk($this->disk)->exists($this->path);
}
public function isProcessed(): bool
{
return $this->processed_at !== null;
}
/**
* Aspect ratio, for reserving the right box before the image arrives.
*/
public function aspectRatio(): ?float
{
if (! $this->width || ! $this->height) {
return null;
}
return round($this->width / $this->height, 4);
}
/**
* Derived versions grouped by format, widest last.
*
* @return array<string, array<int, array{width: int, url: string}>>
*/
public function variantsByFormat(): array
{
$grouped = [];
foreach ($this->variants ?? [] as $variant) {
$grouped[$variant['format']][] = [
'width' => (int) $variant['width'],
'url' => static::absolute(Storage::disk($this->disk)->url($variant['path'])),
];
}
foreach ($grouped as $format => $variants) {
usort($variants, fn (array $a, array $b) => $a['width'] <=> $b['width']);
$grouped[$format] = $variants;
}
return $grouped;
}
/**
* A ready-to-use `srcset` string per format.
*
* Built here rather than in the frontend because only this side knows
* which widths actually exist — and offering one that does not would put
* a broken image on the page.
*
* @return array<string, string>
*/
public function srcset(): array
{
$sets = [];
foreach ($this->variantsByFormat() as $format => $variants) {
$sets[$format] = collect($variants)
->map(fn (array $v) => "{$v['url']} {$v['width']}w")
->implode(', ');
}
return $sets;
}
/**
* Every file this record owns, original included. Used when reprocessing,
* so a second run does not leave the first run's versions behind.
*
* @return array<int, string>
*/
public function derivedPaths(): array
{
return collect($this->variants ?? [])->pluck('path')->all();
}
public function deleteDerived(): void
{
$paths = $this->derivedPaths();
if ($paths !== []) {
Storage::disk($this->disk)->delete($paths);
}
}
/**
* Every path a record currently points at.
*
* The answer to "is anything still using this file?", which is the only
* question that makes deleting one safe.
*
* @return array<int, string>
*/
public static function referencedPaths(): array
{
$paths = [];
$sources = [
[Page::class, 'featured_image'],
[Project::class, 'cover_image'],
[Member::class, 'avatar'],
[Client::class, 'logo'],
[Testimonial::class, 'avatar'],
[ImageAttachment::class, 'path'],
];
foreach ($sources as [$model, $column]) {
$query = $model::query()->whereNotNull($column);
// Something in the bin can still be restored, so its images are
// still in use — deleting them would restore a broken record.
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
$query->withTrashed();
}
$paths = [...$paths, ...$query->pluck($column)->all()];
}
return array_values(array_unique(array_filter($paths)));
}
/**
* Whether any record still points at this file.
*
* The question that decides whether deleting it is safe, and the reason
* the panel refuses to delete one that is in use.
*/
public function isReferenced(): bool
{
return in_array($this->path, static::referencedPaths(), true);
}
/**
* Total bytes on disk, original and every derived version.
*/
public function totalSize(): int
{
return (int) $this->size + collect($this->variants ?? [])->sum('size');
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopePending(Builder $query): Builder
{
return $query->whereNull('processed_at');
}
/**
* Disks configured with a relative URL (the `local` driver's own route,
* for instance) would hand the frontend a link it cannot resolve.
*/
protected static function absolute(string $url): string
{
if (Str::startsWith($url, ['http://', 'https://'])) {
return $url;
}
return rtrim((string) config('app.url'), '/').'/'.ltrim($url, '/');
}
}