From 5fb4f1280b8f7b4b4d8f6137e0a42c7fb39a3490 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Thu, 9 Oct 2025 23:09:01 +0200 Subject: [PATCH 1/2] -Update README.md --- composer.json | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/composer.json b/composer.json index bd183bc..792c7ed 100644 --- a/composer.json +++ b/composer.json @@ -1,34 +1,14 @@ { "name": "ginkelsoft/laravel-encrypted-search-index", - "description": "Encrypted and privacy-preserving search indexing for Laravel models.", + "description": "Encrypted and searchable index for Laravel models with deterministic hashing and prefix tokens.", + "keywords": ["laravel", "encryption", "search", "privacy", "gdpr", "secure-index"], "type": "library", "license": "MIT", - "authors": [ - { - "name": "Wietse van Ginkel", - "email": "info@ginkelsoft.com" - } - ], - "require": { - "php": "^7.4 || ^8.0 || ^8.1 || ^8.2 || ^8.3", - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", - "illuminate/database": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0" - }, - "require-dev": { - "orchestra/testbench": "^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0", - "phpunit/phpunit": "^9.6 || ^10.0 || ^11.0" - }, "autoload": { "psr-4": { "Ginkelsoft\\EncryptedSearch\\": "src/" } }, - "autoload-dev": { - "psr-4": { - "Ginkelsoft\\EncryptedSearch\\Tests\\": "tests/", - "Tests\\": "tests/" - } - }, "extra": { "laravel": { "providers": [ @@ -36,6 +16,14 @@ ] } }, + "require": { + "php": "^8.1", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0", + "phpunit/phpunit": "^10.0|^11.0" + }, "minimum-stability": "stable", "prefer-stable": true } From f7c195a91f092f91fac67a635363c7dcee262b96 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Fri, 10 Oct 2025 06:52:22 +0200 Subject: [PATCH 2/2] feat: add global SearchIndexObserver to automatically maintain encrypted search indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a new global observer (`SearchIndexObserver`) that listens to all Eloquent model events via `Event::listen('eloquent.*: *', ...)`. The observer automatically rebuilds or removes search index entries for models using the `HasEncryptedSearchIndex` trait. Key changes: - Added `Ginkelsoft\EncryptedSearch\Observers\SearchIndexObserver` class - Handles `created`, `updated`, `saved`, `touched`, and `restored` events → reindex - Handles `deleted` and `forceDeleted` events → remove index - Added support for recursive trait detection via `class_uses_recursive` - Requires trait methods `updateSearchIndex()` and `removeSearchIndex()` to be public This ensures consistent and automatic synchronization between model lifecycle events and the encrypted search index, including `touch()` and soft delete operations. --- README.md | 3 +- src/EncryptedSearchServiceProvider.php | 5 ++ src/Observers/SearchIndexObserver.php | 119 +++++++++++++++++++++++++ src/Traits/HasEncryptedSearchIndex.php | 20 +++-- 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/Observers/SearchIndexObserver.php diff --git a/README.md b/README.md index 6e39bef..7cbbaa5 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ This design follows a **defense-in-depth** model: encrypted data stays secure, w ```bash composer require ginkelsoft/laravel-encrypted-search-index -php artisan vendor:publish --tag=config +php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=config +php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=migrations php artisan migrate ``` diff --git a/src/EncryptedSearchServiceProvider.php b/src/EncryptedSearchServiceProvider.php index 9582fe0..75542db 100644 --- a/src/EncryptedSearchServiceProvider.php +++ b/src/EncryptedSearchServiceProvider.php @@ -2,6 +2,8 @@ namespace Ginkelsoft\EncryptedSearch; +use Illuminate\Support\Facades\Event; +use Ginkelsoft\EncryptedSearch\Observers\SearchIndexObserver; use Illuminate\Support\ServiceProvider; /** @@ -79,5 +81,8 @@ public function boot(): void Console\RebuildIndex::class, ]); } + + // 🔹 Register the observer for all Eloquent events + Event::listen('eloquent.*: *', SearchIndexObserver::class); } } diff --git a/src/Observers/SearchIndexObserver.php b/src/Observers/SearchIndexObserver.php new file mode 100644 index 0000000..7d3c8c6 --- /dev/null +++ b/src/Observers/SearchIndexObserver.php @@ -0,0 +1,119 @@ +usesTrait($model, HasEncryptedSearchIndex::class)) { + return; + } + + $eventLower = strtolower($event); + + // Handle deletion events (deleted or forceDeleted) + if (str_contains($eventLower, 'deleted')) { + $this->removeIndex($model); + return; + } + + // Handle write and restore events that require index rebuilding + if ( + str_contains($eventLower, 'saved') || + str_contains($eventLower, 'updated') || + str_contains($eventLower, 'created') || + str_contains($eventLower, 'touched') || + str_contains($eventLower, 'restored') + ) { + $this->rebuildIndex($model); + } + } + + /** + * Determines whether the given model uses a specific trait. + * + * @param Model $model The model instance to inspect. + * @param string $traitFqcn The fully-qualified trait class name to check. + * @return bool + */ + protected function usesTrait(Model $model, string $traitFqcn): bool + { + $uses = class_uses_recursive($model); + return in_array($traitFqcn, $uses, true); + } + + /** + * Rebuilds the search index for the given model. + * + * If the model defines the static method `updateSearchIndex`, + * it will be called directly. This method is typically defined + * in the {@see HasEncryptedSearchIndex} trait. + * + * @param Model $model The model instance to reindex. + * @return void + */ + protected function rebuildIndex(Model $model): void + { + if (method_exists($model, 'updateSearchIndex')) { + // @phpstan-ignore-next-line + $model::updateSearchIndex($model); + } + } + + /** + * Removes all index entries for the given model. + * + * If the model defines the static method `removeSearchIndex`, + * it will be invoked to clear existing tokens associated with + * the model’s primary key. + * + * @param Model $model The model instance to remove from the index. + * @return void + */ + protected function removeIndex(Model $model): void + { + if (method_exists($model, 'removeSearchIndex')) { + // @phpstan-ignore-next-line + $model::removeSearchIndex($model); + } + } +} diff --git a/src/Traits/HasEncryptedSearchIndex.php b/src/Traits/HasEncryptedSearchIndex.php index 6ca84d5..b81ceb2 100644 --- a/src/Traits/HasEncryptedSearchIndex.php +++ b/src/Traits/HasEncryptedSearchIndex.php @@ -75,13 +75,21 @@ trait HasEncryptedSearchIndex */ public static function bootHasEncryptedSearchIndex(): void { - static::saved(function (Model $model): void { - static::updateSearchIndex($model); - }); + // Rebuild index when model is created, updated or saved. + foreach (['created', 'updated', 'saved'] as $event) { + static::$event(function (Model $model) { + static::updateSearchIndex($model); + }); + } - static::deleted(function (Model $model): void { - static::removeSearchIndex($model); - }); + // Remove tokens when model is deleted or force-deleted + static::deleted(fn(Model $m) => static::removeSearchIndex($m)); + static::forceDeleted(fn(Model $m) => static::removeSearchIndex($m)); + + // Optional: if SoftDeletes is used, re-index on restore + if (method_exists(static::class, 'restored')) { + static::restored(fn(Model $m) => static::updateSearchIndex($m)); + } } /**