Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions boot/Events/ItemFileCleanupListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Scriptor\Boot\Events;

use Imanager\Domain\Event\ItemDeleted;
use Imanager\Files\FileStorage;
use Imanager\Storage\FileRepository;

/**
* Listener for `ItemDeleted` — wipes uploaded files (and their
* thumbnails) from the file-storage backend before the FK cascade on
* `items` ↦ `files` removes the metadata rows.
*
* The dispatcher fires `ItemDeleted` from
* {@see \Imanager\Storage\Sqlite\SqliteItemRepository::delete()}
* **before** the SQL DELETE runs (Plan §14e contract), so the
* FileRepository can still resolve the rows here. The metadata rows
* disappear automatically with the cascade once delete() returns;
* we only need to scrub the on-disk side.
*
* Thumbnails are stored under `<dir>/thumbnail/<W>x<H>_<file>` (the
* convention shared by Frontend\ImageUrlBuilder and the upload
* endpoint) — we walk the thumbnail directory so this works for any
* thumbnail size, not just the ones we currently generate.
*/
final readonly class ItemFileCleanupListener
{
public function __construct(
private FileRepository $files,
private FileStorage $storage,
) {}

public function __invoke(ItemDeleted $event): void
{
$files = $this->files->findByItem($event->itemId);
if ($files === []) {
return;
}

foreach ($files as $file) {
// Delete the asset itself.
if ($this->storage->exists($file->path)) {
$this->storage->delete($file->path);
}
// Plus every thumbnail size that was ever generated for it.
$this->purgeThumbnails($file->path, $file->name);
}
}

/**
* Walks `<dir>/thumbnail/` and removes every `<W>x<H>_<file>` entry
* for the given filename. Tolerates a missing directory (no
* thumbnails generated yet) and unreadable / non-image siblings.
*/
private function purgeThumbnails(string $assetPath, string $name): void
{
$thumbDirRel = \dirname($assetPath) . '/thumbnail';
$thumbDirAbs = $this->storage->absolutePath($thumbDirRel);
if (! is_dir($thumbDirAbs)) {
return;
}
$entries = scandir($thumbDirAbs);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
// Match `<W>x<H>_<name>` exactly so we never delete a sibling
// file uploaded with a different stem.
if (! preg_match('/^\d+x\d+_/', $entry) || ! str_ends_with($entry, '_' . $name)) {
continue;
}
$thumbRel = $thumbDirRel . '/' . $entry;
if ($this->storage->exists($thumbRel)) {
$this->storage->delete($thumbRel);
}
}
}
}
51 changes: 51 additions & 0 deletions boot/Events/PageCacheInvalidationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Scriptor\Boot\Events;

use Imanager\Domain\Event\DomainEvent;
use Imanager\Domain\Event\ItemCreated;
use Imanager\Domain\Event\ItemDeleted;
use Imanager\Domain\Event\ItemUpdated;
use Psr\SimpleCache\CacheInterface;

/**
* Listener for `ItemCreated` / `ItemUpdated` / `ItemDeleted` —
* flushes the rendered-page cache whenever a page in the target
* category mutates. Other categories (Users, custom data) don't
* touch this cache and are ignored.
*
* Plan §14e suggests per-URL invalidation, but the cache key in
* BasicTheme is hashed from `host + REQUEST_URI` and we don't know
* either of those at event-time. Until the cache adopts tags or
* per-page prefixes we settle for a global `clear()` of the section
* cache — heavy-handed but correct, and acceptable because the
* filesystem cache is currently only used for rendered HTML.
*/
final readonly class PageCacheInvalidationListener
{
public function __construct(
private CacheInterface $cache,
private int $watchedCategoryId,
) {}

public function __invoke(DomainEvent $event): void
{
$categoryId = $this->extractCategoryId($event);
if ($categoryId === null || $categoryId !== $this->watchedCategoryId) {
return;
}
$this->cache->clear();
}

private function extractCategoryId(DomainEvent $event): ?int
{
return match (true) {
$event instanceof ItemCreated => $event->item->categoryId,
$event instanceof ItemUpdated => $event->current->categoryId,
$event instanceof ItemDeleted => $event->categoryId,
default => null,
};
}
}
76 changes: 75 additions & 1 deletion boot/ImanagerBootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Imanager\Bootstrap;
use Imanager\Cache\FilesystemCache;
use Imanager\Events\SubscriberListenerProvider;
use Imanager\Events\SyncEventDispatcher;
use Imanager\Field\FieldTypeRegistry;
use Imanager\Field\Types\ArrayListFieldType;
use Imanager\Field\Types\CheckboxFieldType;
Expand Down Expand Up @@ -41,6 +43,10 @@
use Imanager\Storage\Storage;
use Imanager\Validation\Sanitizer;
use League\Container\Container;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Scriptor\Boot\Events\ItemFileCleanupListener;
use Scriptor\Boot\Events\PageCacheInvalidationListener;

/**
* Wires the iManager 2.0 service graph for use inside Scriptor.
Expand Down Expand Up @@ -81,8 +87,22 @@ public static function create(string $scriptorRoot, array $paths = []): Containe

$container->addShared(Sanitizer::class, static fn(): Sanitizer => new Sanitizer());

// 14e: PSR-14 dispatcher + listener provider. The same provider
// instance is reachable via both interfaces so listeners can
// subscribe through the typed bind, while storage and other
// dispatch sites use the dispatcher.
$container->addShared(SubscriberListenerProvider::class, static fn(): SubscriberListenerProvider
=> new SubscriberListenerProvider());
$container->addShared(ListenerProviderInterface::class, static fn(): ListenerProviderInterface
=> $container->get(SubscriberListenerProvider::class));
$container->addShared(EventDispatcherInterface::class, static fn(): EventDispatcherInterface
=> new SyncEventDispatcher($container->get(ListenerProviderInterface::class)));

$container->addShared(SqliteStorage::class, static fn(): SqliteStorage
=> new SqliteStorage($container->get(\PDO::class)));
=> new SqliteStorage(
$container->get(\PDO::class),
$container->get(EventDispatcherInterface::class),
));
$container->addShared(Storage::class, static fn(): Storage
=> $container->get(SqliteStorage::class));

Expand Down Expand Up @@ -114,6 +134,10 @@ public static function create(string $scriptorRoot, array $paths = []): Containe
$container->addShared(Csrf::class, static fn(): Csrf
=> new Csrf($container->get(SessionStore::class), maxTokens: 10));

// 14e domain-event listeners. Side effects fire in-process from
// SqliteStorage save/delete (synchronous PSR-14 dispatch).
self::wireDomainEventListeners($container);

$container->addShared(FieldTypeRegistry::class, static function () use ($container): FieldTypeRegistry {
$registry = new FieldTypeRegistry();
$sanitizer = $container->get(Sanitizer::class);
Expand Down Expand Up @@ -142,4 +166,54 @@ public static function create(string $scriptorRoot, array $paths = []): Containe
private function __construct()
{
}

/**
* Subscribes Phase 14e listeners on the container's
* {@see SubscriberListenerProvider}. Listener instantiation is
* deferred until the first matching event fires, so a request
* that doesn't touch any item never even constructs the listeners.
*/
private static function wireDomainEventListeners(Container $container): void
{
/** @var SubscriberListenerProvider $provider */
$provider = $container->get(SubscriberListenerProvider::class);

$provider->subscribe(
\Imanager\Domain\Event\ItemDeleted::class,
static function (\Imanager\Domain\Event\ItemDeleted $event) use ($container): void {
/** @var ItemFileCleanupListener $listener */
static $listener = null;
if ($listener === null) {
$listener = new ItemFileCleanupListener(
$container->get(FileRepository::class),
$container->get(FileStorage::class),
);
}
$listener($event);
},
);

// Cache-invalidation: only when a Pages-category item mutates.
// Resolve the watched id lazily so the listener factory doesn't
// hit the DB on every request — we resolve once and then keep
// the same listener instance.
$cacheInvalidator = static function (object $event) use ($container): void {
/** @var PageCacheInvalidationListener|null $listener */
static $listener = null;
if ($listener === null) {
$pages = $container->get(CategoryRepository::class)->findBySlug('pages');
if ($pages === null || $pages->id === null) {
return; // no Pages category — nothing to invalidate
}
$listener = new PageCacheInvalidationListener(
$container->get(FilesystemCache::class),
$pages->id,
);
}
$listener($event);
};
$provider->subscribe(\Imanager\Domain\Event\ItemCreated::class, $cacheInvalidator);
$provider->subscribe(\Imanager\Domain\Event\ItemUpdated::class, $cacheInvalidator);
$provider->subscribe(\Imanager\Domain\Event\ItemDeleted::class, $cacheInvalidator);
}
}
53 changes: 52 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.