From 96747abdc30fd93095d11561b4618ea5d9f6ee53 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 29 Sep 2026 23:09:25 +0200 Subject: [PATCH] Keep technology logos and images inside text safe from pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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 --- .../Resources/Pages/Schemas/PageForm.php | 9 ++ app/Models/Media.php | 44 +++++++++ tests/Feature/Media/ReferencedPathsTest.php | 92 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 tests/Feature/Media/ReferencedPathsTest.php diff --git a/app/Filament/Resources/Pages/Schemas/PageForm.php b/app/Filament/Resources/Pages/Schemas/PageForm.php index d185143..357801b 100644 --- a/app/Filament/Resources/Pages/Schemas/PageForm.php +++ b/app/Filament/Resources/Pages/Schemas/PageForm.php @@ -75,6 +75,15 @@ class PageForm RichEditor::make("body.{$locale}") ->label(__('panel.pages.fields.body')) + // Images in the text go where every other upload + // does, public: the HTML travels to the frontend + // as it is, with each image's URL written in, and + // a private file would be a broken image there. + // Media::referencedPaths() finds them in the body, + // so pruning never takes one a page still shows. + ->fileAttachmentsDisk(config('media.disk')) + ->fileAttachmentsDirectory('pages/content') + ->fileAttachmentsVisibility('public') ->columnSpanFull(), ]), ]), diff --git a/app/Models/Media.php b/app/Models/Media.php index 7277bda..adfe458 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -183,6 +183,7 @@ class Media extends Model [Member::class, 'avatar'], [Client::class, 'logo'], [Testimonial::class, 'avatar'], + [Technology::class, 'logo'], [ImageAttachment::class, 'path'], ]; @@ -198,9 +199,52 @@ class Media extends Model $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. * diff --git a/tests/Feature/Media/ReferencedPathsTest.php b/tests/Feature/Media/ReferencedPathsTest.php new file mode 100644 index 0000000..9271a75 --- /dev/null +++ b/tests/Feature/Media/ReferencedPathsTest.php @@ -0,0 +1,92 @@ +create(['logo' => 'technologies/angular.svg']); + + $this->assertContains('technologies/angular.svg', Media::referencedPaths()); + } + + #[Test] + public function an_image_inside_a_page_body_is_in_use_in_any_language(): void + { + $url = Storage::disk((string) config('media.disk'))->url('pages/content/diagrama.png'); + + Page::factory()->create([ + 'body' => [ + 'es' => '

Así funciona:

Diagrama', + 'en' => '
', + ], + ]); + + $paths = Media::referencedPaths(); + + $this->assertContains('pages/content/diagrama.png', $paths); + $this->assertContains('pages/content/diagram en.png', $paths); + } + + #[Test] + public function an_image_in_a_projects_prose_is_in_use_even_in_the_bin(): void + { + Project::factory()->create([ + 'decisions' => ['es' => ''], + ])->delete(); + + $this->assertContains('projects/arquitectura.webp', Media::referencedPaths()); + } + + #[Test] + public function pruning_leaves_an_image_a_body_shows_and_takes_a_true_orphan(): void + { + $disk = Storage::disk((string) config('media.disk')); + + foreach (['pages/content/usada.png', 'pages/huerfana.png'] as $path) { + $disk->put($path, 'x'); + Media::query()->create(['disk' => (string) config('media.disk'), 'path' => $path]); + } + + Page::factory()->create(['body' => ['es' => '']]); + + $this->artisan('content:prune', ['--media' => true])->assertSuccessful(); + + $disk->assertExists('pages/content/usada.png'); + $disk->assertMissing('pages/huerfana.png'); + } + + #[Test] + public function html_without_images_on_the_disk_says_nothing(): void + { + $this->assertSame([], Media::pathsEmbeddedIn('

Texto

')); + } +}