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>
This commit is contained in:
@@ -75,6 +75,15 @@ class PageForm
|
|||||||
|
|
||||||
RichEditor::make("body.{$locale}")
|
RichEditor::make("body.{$locale}")
|
||||||
->label(__('panel.pages.fields.body'))
|
->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(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ class Media extends Model
|
|||||||
[Member::class, 'avatar'],
|
[Member::class, 'avatar'],
|
||||||
[Client::class, 'logo'],
|
[Client::class, 'logo'],
|
||||||
[Testimonial::class, 'avatar'],
|
[Testimonial::class, 'avatar'],
|
||||||
|
[Technology::class, 'logo'],
|
||||||
[ImageAttachment::class, 'path'],
|
[ImageAttachment::class, 'path'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,9 +199,52 @@ class Media extends Model
|
|||||||
$paths = [...$paths, ...$query->pluck($column)->all()];
|
$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)));
|
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.
|
* Whether any record still points at this file.
|
||||||
*
|
*
|
||||||
|
|||||||
92
tests/Feature/Media/ReferencedPathsTest.php
Normal file
92
tests/Feature/Media/ReferencedPathsTest.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Media;
|
||||||
|
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What counts as "in use" decides what `content:prune --media` may delete, so
|
||||||
|
* everything a record shows has to be found here — or pruning breaks it.
|
||||||
|
*/
|
||||||
|
class ReferencedPathsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Queue::fake();
|
||||||
|
Storage::fake((string) config('media.disk'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_technologys_logo_is_in_use(): void
|
||||||
|
{
|
||||||
|
Technology::factory()->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' => '<p>Así funciona:</p><img src="'.$url.'" alt="Diagrama">',
|
||||||
|
'en' => '<figure><img src="/storage/pages/content/diagram%20en.png"></figure>',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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' => '<img src="/storage/projects/arquitectura.webp">'],
|
||||||
|
])->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' => '<img src="'.$disk->url('pages/content/usada.png').'">']]);
|
||||||
|
|
||||||
|
$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('<p>Texto</p><img src="https://otra-web.example/foto.png">'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user