Media::referencedPaths() is what `content:prune --media` asks before deleting a file, and it did not know about two kinds of image a page shows: - A technology's logo. Every logo on the radar looked orphaned, and one prune would have deleted all of them. - Images inside rich text — a page's body, a project's description, decisions and postmortem — whether attached in the panel's editor or uploaded through the API and written in as <img src>. They are now found in the HTML, in every language, bin included. The page body's editor also states where attachments go — the media disk, pages/content, public — instead of relying on Filament's defaults: the HTML reaches the frontend as it is, and a private file would be a broken image there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
8.6 KiB
PHP
289 lines
8.6 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'],
|
|
[Technology::class, 'logo'],
|
|
[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()];
|
|
}
|
|
|
|
// Images placed inside rich text — attached in the panel's editor, or
|
|
// uploaded through the API and written into a body as `<img src>`. No
|
|
// column holds their path, so without this they would look orphaned
|
|
// and `content:prune --media` would delete them from under the page.
|
|
$richText = [
|
|
[Page::class, ['body']],
|
|
[Project::class, ['description', 'decisions', 'postmortem']],
|
|
];
|
|
|
|
foreach ($richText as [$model, $columns]) {
|
|
$model::query()
|
|
->withTrashed()
|
|
->select(['id', ...$columns])
|
|
->chunkById(200, function ($records) use ($columns, &$paths): void {
|
|
foreach ($records as $record) {
|
|
foreach ($columns as $column) {
|
|
foreach ($record->getTranslations($column) as $html) {
|
|
$paths = [...$paths, ...static::pathsEmbeddedIn((string) $html)];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($paths)));
|
|
}
|
|
|
|
/**
|
|
* The stored paths of every image on the media disk a piece of HTML links
|
|
* to, whether written with the disk's absolute URL or its path alone.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function pathsEmbeddedIn(string $html): array
|
|
{
|
|
$base = (string) parse_url(Storage::disk((string) config('media.disk', 'public'))->url(''), PHP_URL_PATH);
|
|
|
|
if ($base === '' || ! str_contains($html, $base)) {
|
|
return [];
|
|
}
|
|
|
|
preg_match_all('#'.preg_quote(rtrim($base, '/'), '#').'/([^"\'\s?\#<>]+)#', $html, $matches);
|
|
|
|
return array_map('rawurldecode', $matches[1]);
|
|
}
|
|
|
|
/**
|
|
* 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, '/');
|
|
}
|
|
}
|