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>
245 lines
6.8 KiB
PHP
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, '/');
|
|
}
|
|
}
|