Files
landing-api/app/Models/Media.php
Josep Piguillem 96747abdc3
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Keep technology logos and images inside text safe from pruning
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>
2026-09-29 23:09:25 +02:00

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, '/');
}
}