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