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/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 } 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)); + } } /**