Skip to content

Commit 5533c03

Browse files
Merge pull request #29 from ginkelsoft-development/develop
develop into main
2 parents 0964003 + 6dc4c6e commit 5533c03

File tree

5 files changed

+807
-2
lines changed

5 files changed

+807
-2
lines changed

.github/dependabot.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
version: 2
2+
updates:
3+
# 1️⃣ PHP dependencies via Composer
4+
- package-ecosystem: "composer"
5+
directory: "/" # Root van je Laravel project
6+
schedule:
7+
interval: "weekly"
8+
open-pull-requests-limit: 5
9+
commit-message:
10+
prefix: "chore(deps)"
11+
include: "scope"
12+
labels:
13+
- "dependencies"
14+
- "php"
15+
16+
# 2️⃣ JavaScript dependencies (Tailwind, Alpine, etc.)
17+
- package-ecosystem: "npm"
18+
directory: "/" # package.json staat in root
19+
schedule:
20+
interval: "weekly"
21+
open-pull-requests-limit: 5
22+
commit-message:
23+
prefix: "chore(deps)"
24+
include: "scope"
25+
labels:
26+
- "dependencies"
27+
- "frontend"

README.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,16 +187,72 @@ SEARCH_PEPPER=your-random-secret-string
187187

188188
```php
189189
return [
190+
// Secret pepper for token hashing
190191
'search_pepper' => env('SEARCH_PEPPER', ''),
192+
193+
// Maximum prefix depth for token generation
191194
'max_prefix_depth' => 6,
195+
196+
// Minimum prefix length for search queries (default: 3)
197+
'min_prefix_length' => env('ENCRYPTED_SEARCH_MIN_PREFIX', 3),
198+
199+
// Automatic indexing of encrypted casts
200+
'auto_index_encrypted_casts' => true,
201+
202+
// Elasticsearch integration
192203
'elasticsearch' => [
193-
'enabled' => env('ENCRYPTED_SEARCH_DRIVER', 'database') === 'elasticsearch',
194-
'host' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
204+
'enabled' => env('ENCRYPTED_SEARCH_ELASTIC_ENABLED', false),
205+
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
195206
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
196207
],
208+
209+
// Debug logging
210+
'debug' => env('ENCRYPTED_SEARCH_DEBUG', false),
197211
];
198212
```
199213

214+
### Configuration Options
215+
216+
| Option | Default | Description |
217+
|--------|---------|-------------|
218+
| `search_pepper` | `''` | Secret pepper value for token hashing. **Required for security.** |
219+
| `max_prefix_depth` | `6` | Maximum number of prefix characters to index (e.g., "wietse" → w, wi, wie, wiet, wiets, wietse) |
220+
| `min_prefix_length` | `3` | Minimum search term length for prefix queries. Prevents overly broad matches from short terms like "w" or "de". |
221+
| `auto_index_encrypted_casts` | `true` | Automatically index fields with `encrypted` cast types |
222+
| `elasticsearch.enabled` | `false` | Use Elasticsearch instead of database for token storage |
223+
| `elasticsearch.host` | `http://elasticsearch:9200` | Elasticsearch host URL |
224+
| `elasticsearch.index` | `encrypted_search` | Elasticsearch index name |
225+
| `debug` | `false` | Enable debug logging for index operations |
226+
227+
### Minimum Prefix Length
228+
229+
The `min_prefix_length` setting prevents performance issues and false positives from very short search terms.
230+
231+
**Example with `min_prefix_length = 3` (default):**
232+
233+
```php
234+
// ❌ Returns no results (too short)
235+
Client::encryptedPrefix('first_names', 'Wi')->get();
236+
237+
// ✅ Works normally (meets minimum)
238+
Client::encryptedPrefix('first_names', 'Wil')->get(); // Finds "Wilma"
239+
240+
// ✅ Exact search always works (ignores minimum)
241+
Client::encryptedExact('first_names', 'Wi')->get();
242+
```
243+
244+
**Recommended values:**
245+
- `1`: Allow single-character searches (more flexible, more false positives)
246+
- `2`: Require two characters (good for short names)
247+
- `3`: Require three characters (recommended - good balance)
248+
- `4`: Require four characters (very precise, less flexible)
249+
250+
To adjust this setting, add to your `.env`:
251+
252+
```env
253+
ENCRYPTED_SEARCH_MIN_PREFIX=3
254+
```
255+
200256
---
201257

202258
## Usage
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Services;
4+
5+
use Illuminate\Support\Facades\Cache;
6+
7+
/**
8+
* Class SearchCacheService
9+
*
10+
* Provides caching functionality for encrypted search results.
11+
*
12+
* Caches search results to reduce database/Elasticsearch queries for
13+
* frequently performed searches. Automatically invalidates cache when
14+
* models are updated or deleted.
15+
*
16+
* Cache keys are generated based on:
17+
* - Model class
18+
* - Search type (exact/prefix/any/all)
19+
* - Search parameters (fields, terms)
20+
* - Configuration hash (pepper, depths, etc.)
21+
*
22+
* Example usage:
23+
* ```php
24+
* $service = app(SearchCacheService::class);
25+
* $results = $service->remember('Client', 'exact', ['first_names', 'John'], function() {
26+
* return Client::encryptedExact('first_names', 'John')->pluck('id')->toArray();
27+
* });
28+
* ```
29+
*/
30+
class SearchCacheService
31+
{
32+
/**
33+
* Cache TTL in seconds (default: 1 hour).
34+
*
35+
* @var int
36+
*/
37+
protected int $ttl;
38+
39+
/**
40+
* Cache key prefix to avoid collisions.
41+
*
42+
* @var string
43+
*/
44+
protected string $prefix = 'encrypted_search:';
45+
46+
/**
47+
* Create a new SearchCacheService instance.
48+
*/
49+
public function __construct()
50+
{
51+
$this->ttl = (int) config('encrypted-search.cache.ttl', 3600);
52+
}
53+
54+
/**
55+
* Remember a search result in cache.
56+
*
57+
* @param string $modelClass The model class name
58+
* @param string $searchType The search type (exact/prefix/any/all)
59+
* @param array<mixed> $params Search parameters
60+
* @param callable $callback Callback to execute if cache miss
61+
* @return array<int, mixed> Array of model IDs
62+
*/
63+
public function remember(string $modelClass, string $searchType, array $params, callable $callback): array
64+
{
65+
if (!$this->isEnabled()) {
66+
return $callback();
67+
}
68+
69+
$key = $this->generateKey($modelClass, $searchType, $params);
70+
71+
return Cache::remember($key, $this->ttl, $callback);
72+
}
73+
74+
/**
75+
* Invalidate all cached searches for a specific model instance.
76+
*
77+
* Called when a model is updated or deleted.
78+
*
79+
* @param string $modelClass The model class name
80+
* @param mixed $modelId The model ID (optional, if null invalidates all for class)
81+
* @return void
82+
*/
83+
public function invalidate(string $modelClass, $modelId = null): void
84+
{
85+
if (!$this->isEnabled()) {
86+
return;
87+
}
88+
89+
// Invalidate by tag if driver supports it (Redis, Memcached)
90+
if ($this->supportsTagging()) {
91+
$tags = $modelId
92+
? [$this->getModelTag($modelClass), $this->getInstanceTag($modelClass, $modelId)]
93+
: [$this->getModelTag($modelClass)];
94+
95+
Cache::tags($tags)->flush();
96+
} else {
97+
// Fallback: invalidate by pattern (only works with some drivers)
98+
$this->invalidateByPattern($modelClass, $modelId);
99+
}
100+
}
101+
102+
/**
103+
* Completely flush all encrypted search caches.
104+
*
105+
* @return void
106+
*/
107+
public function flush(): void
108+
{
109+
if (!$this->isEnabled()) {
110+
return;
111+
}
112+
113+
if ($this->supportsTagging()) {
114+
Cache::tags(['encrypted_search'])->flush();
115+
} else {
116+
// Note: This is cache driver specific and may not work everywhere
117+
Cache::flush();
118+
}
119+
}
120+
121+
/**
122+
* Generate a unique cache key for a search.
123+
*
124+
* @param string $modelClass
125+
* @param string $searchType
126+
* @param array<mixed> $params
127+
* @return string
128+
*/
129+
protected function generateKey(string $modelClass, string $searchType, array $params): string
130+
{
131+
// Include configuration hash to auto-invalidate when config changes
132+
$configHash = $this->getConfigHash();
133+
134+
// Create deterministic key from parameters
135+
$paramsHash = md5(json_encode($params));
136+
137+
return $this->prefix . md5(
138+
$modelClass . ':' . $searchType . ':' . $paramsHash . ':' . $configHash
139+
);
140+
}
141+
142+
/**
143+
* Get a hash of relevant configuration values.
144+
*
145+
* When configuration changes, all caches should be invalidated.
146+
*
147+
* @return string
148+
*/
149+
protected function getConfigHash(): string
150+
{
151+
return md5(json_encode([
152+
config('encrypted-search.search_pepper'),
153+
config('encrypted-search.max_prefix_depth'),
154+
config('encrypted-search.min_prefix_length'),
155+
]));
156+
}
157+
158+
/**
159+
* Get cache tag for a model class.
160+
*
161+
* @param string $modelClass
162+
* @return string
163+
*/
164+
protected function getModelTag(string $modelClass): string
165+
{
166+
return 'encrypted_search:model:' . md5($modelClass);
167+
}
168+
169+
/**
170+
* Get cache tag for a specific model instance.
171+
*
172+
* @param string $modelClass
173+
* @param mixed $modelId
174+
* @return string
175+
*/
176+
protected function getInstanceTag(string $modelClass, $modelId): string
177+
{
178+
return 'encrypted_search:instance:' . md5($modelClass . ':' . $modelId);
179+
}
180+
181+
/**
182+
* Check if the cache driver supports tagging.
183+
*
184+
* @return bool
185+
*/
186+
protected function supportsTagging(): bool
187+
{
188+
$driver = config('cache.default');
189+
$supportedDrivers = ['redis', 'memcached', 'dynamodb', 'octane'];
190+
191+
return in_array($driver, $supportedDrivers, true);
192+
}
193+
194+
/**
195+
* Invalidate cache by pattern (fallback for drivers without tagging).
196+
*
197+
* Note: This only works with some cache drivers.
198+
*
199+
* @param string $modelClass
200+
* @param mixed $modelId
201+
* @return void
202+
*/
203+
protected function invalidateByPattern(string $modelClass, $modelId = null): void
204+
{
205+
// This is a simplified implementation
206+
// In production, you might want to track cache keys in a separate store
207+
// or use a cache driver that supports pattern-based deletion
208+
209+
// For now, we'll just clear the entire cache as a safe fallback
210+
// Individual drivers can implement more efficient pattern matching
211+
Cache::flush();
212+
}
213+
214+
/**
215+
* Check if caching is enabled.
216+
*
217+
* @return bool
218+
*/
219+
protected function isEnabled(): bool
220+
{
221+
return config('encrypted-search.cache.enabled', false);
222+
}
223+
}

0 commit comments

Comments
 (0)