Skip to content

Commit 0964003

Browse files
Merge pull request #25 from ginkelsoft-development/develop
Develop into Main
2 parents cf92e2d + 09e8e92 commit 0964003

16 files changed

+1729
-35
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
],
2121
"require": {
2222
"php": "^8.1 || ^8.2 || ^8.3 || ^8.4",
23-
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0"
23+
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0",
24+
"ext-intl": "*"
2425
},
2526
"require-dev": {
2627
"phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0",

config/encrypted-search.php

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
|--------------------------------------------------------------------------
3737
|
3838
| The maximum number of prefix levels to generate for prefix-based search.
39-
| For example, the term wietse would generate:
39+
| For example, the term "wietse" would generate:
4040
| ["w", "wi", "wie", "wiet", "wiets", "wietse"]
4141
|
4242
| Increasing this value improves search precision for short terms, but
@@ -45,6 +45,29 @@
4545
*/
4646
'max_prefix_depth' => 6,
4747

48+
/*
49+
|--------------------------------------------------------------------------
50+
| Minimum Prefix Length
51+
|--------------------------------------------------------------------------
52+
|
53+
| The minimum number of characters required for prefix-based searches.
54+
| This prevents overly broad matches from very short search terms.
55+
|
56+
| For example, with min_prefix_length = 3:
57+
| - Searching for "Wi" (2 chars) will return no results
58+
| - Searching for "Wil" (3 chars) will work normally
59+
|
60+
| This helps prevent performance issues and reduces false positives
61+
| when users search for very short terms like "a" or "de".
62+
|
63+
| Recommended values:
64+
| - 2: Allow two-character searches (more flexible, more false positives)
65+
| - 3: Require three characters (good balance)
66+
| - 4: Require four characters (very precise, less flexible)
67+
|
68+
*/
69+
'min_prefix_length' => env('ENCRYPTED_SEARCH_MIN_PREFIX', 3),
70+
4871
/*
4972
|--------------------------------------------------------------------------
5073
| Automatic Indexing of Encrypted Casts
@@ -82,4 +105,18 @@
82105
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
83106
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
84107
],
108+
109+
/*
110+
|--------------------------------------------------------------------------
111+
| Debug Logging
112+
|--------------------------------------------------------------------------
113+
|
114+
| Enable debug logging for encrypted search operations. When enabled,
115+
| the package will log token generation, index updates, and deletions
116+
| to help with debugging and monitoring.
117+
|
118+
| Warning: This can generate a lot of log entries in high-traffic applications.
119+
|
120+
*/
121+
'debug' => env('ENCRYPTED_SEARCH_DEBUG', false),
85122
];

src/Contracts/SearchDriver.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/EncryptedSearchServiceProvider.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ public function register(): void
5353
*/
5454
public function boot(): void
5555
{
56+
// Validate configuration
57+
$this->validateConfiguration();
58+
5659
// Publish configuration
5760
$this->publishes([
5861
__DIR__ . '/../config/encrypted-search.php' => config_path('encrypted-search.php'),
@@ -75,4 +78,35 @@ public function boot(): void
7578
// Listen for all Eloquent model events and route them through the observer
7679
Event::listen('eloquent.*: *', SearchIndexObserver::class);
7780
}
81+
82+
/**
83+
* Validate package configuration at boot time.
84+
*
85+
* @return void
86+
*
87+
* @throws \InvalidArgumentException if configuration is invalid
88+
*/
89+
protected function validateConfiguration(): void
90+
{
91+
// Validate Elasticsearch configuration if enabled
92+
if (config('encrypted-search.elasticsearch.enabled', false)) {
93+
$host = config('encrypted-search.elasticsearch.host');
94+
95+
if (empty($host)) {
96+
throw new \InvalidArgumentException(
97+
'Elasticsearch is enabled but ELASTICSEARCH_HOST is not configured. ' .
98+
'Set it in your .env file or disable Elasticsearch mode.'
99+
);
100+
}
101+
102+
$index = config('encrypted-search.elasticsearch.index');
103+
104+
if (empty($index)) {
105+
throw new \InvalidArgumentException(
106+
'Elasticsearch is enabled but ELASTICSEARCH_INDEX is not configured. ' .
107+
'Set it in your .env file.'
108+
);
109+
}
110+
}
111+
}
78112
}

src/Services/ElasticsearchService.php

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,29 +49,41 @@ public function __construct(?string $host = null)
4949
* @param string $index The Elasticsearch index name.
5050
* @param string $id The unique document ID.
5151
* @param array<string, mixed> $body The document body to be stored.
52-
* @return bool True if successful, false otherwise.
52+
* @return void
53+
*
54+
* @throws \RuntimeException if the request fails
5355
*/
54-
public function indexDocument(string $index, string $id, array $body): bool
56+
public function indexDocument(string $index, string $id, array $body): void
5557
{
5658
$url = "{$this->host}/{$index}/_doc/{$id}";
5759
$response = Http::put($url, $body);
5860

59-
return $response->successful();
61+
if (!$response->successful()) {
62+
throw new \RuntimeException(
63+
"Failed to index document to Elasticsearch [{$url}]: " . $response->body()
64+
);
65+
}
6066
}
6167

6268
/**
6369
* Delete a document from Elasticsearch by its ID.
6470
*
6571
* @param string $index The Elasticsearch index name.
6672
* @param string $id The document ID to delete.
67-
* @return bool True if successful, false otherwise.
73+
* @return void
74+
*
75+
* @throws \RuntimeException if the request fails
6876
*/
69-
public function deleteDocument(string $index, string $id): bool
77+
public function deleteDocument(string $index, string $id): void
7078
{
7179
$url = "{$this->host}/{$index}/_doc/{$id}";
7280
$response = Http::delete($url);
7381

74-
return $response->successful();
82+
if (!$response->successful()) {
83+
throw new \RuntimeException(
84+
"Failed to delete document from Elasticsearch [{$url}]: " . $response->body()
85+
);
86+
}
7587
}
7688

7789
/**
@@ -80,12 +92,35 @@ public function deleteDocument(string $index, string $id): bool
8092
* @param string $index The Elasticsearch index name.
8193
* @param array<string, mixed> $query The Elasticsearch query body.
8294
* @return array<int, mixed> The array of matching documents (hits).
95+
*
96+
* @throws \RuntimeException if the request fails
8397
*/
8498
public function search(string $index, array $query): array
8599
{
86100
$url = "{$this->host}/{$index}/_search";
87101
$response = Http::post($url, $query);
88102

103+
if (!$response->successful()) {
104+
throw new \RuntimeException(
105+
"Failed to search Elasticsearch [{$url}]: " . $response->body()
106+
);
107+
}
108+
89109
return $response->json('hits.hits', []);
90110
}
111+
112+
/**
113+
* Delete documents matching a query from an Elasticsearch index.
114+
*
115+
* @param string $index The Elasticsearch index name.
116+
* @param array<string, mixed> $query The Elasticsearch query body.
117+
* @return bool True if successful, false otherwise.
118+
*/
119+
public function deleteByQuery(string $index, array $query): bool
120+
{
121+
$url = "{$this->host}/{$index}/_delete_by_query";
122+
$response = Http::post($url, $query);
123+
124+
return $response->successful();
125+
}
91126
}

src/Support/Normalizer.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
*
2121
* Features:
2222
* - Lowercases all text (UTF-8 safe)
23-
* - Optionally removes diacritics using PHP’s Normalizer (if available)
23+
* - Removes diacritics using PHP's intl extension (required)
2424
* - Strips all non-alphanumeric characters
25+
*
26+
* Requirements:
27+
* - The intl PHP extension must be installed for consistent normalization
2528
*/
2629
class Normalizer
2730
{
@@ -44,11 +47,9 @@ public static function normalize(?string $v): ?string
4447
// Convert to lowercase (UTF-8 safe)
4548
$s = mb_strtolower($v, 'UTF-8');
4649

47-
// Optionally remove diacritics if intl extension is available
48-
if (class_exists(\Normalizer::class)) {
49-
$s = \Normalizer::normalize($s, \Normalizer::FORM_D);
50-
$s = preg_replace('/\p{M}/u', '', $s); // strip diacritics
51-
}
50+
// Remove diacritics using intl extension
51+
$s = \Normalizer::normalize($s, \Normalizer::FORM_D);
52+
$s = preg_replace('/\p{M}/u', '', $s); // strip diacritics
5253

5354
// Retain only letters and digits
5455
$s = preg_replace('/[^a-z0-9]/u', '', $s);

src/Support/Tokens.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,18 @@ class Tokens
4646
*
4747
* @return string
4848
* Hex-encoded SHA-256 hash (64 characters).
49+
*
50+
* @throws \RuntimeException if pepper is empty
4951
*/
5052
public static function exact(string $normalized, string $pepper): string
5153
{
54+
if (empty($pepper)) {
55+
throw new \RuntimeException(
56+
'SEARCH_PEPPER is not configured. Set it in your .env file for security. ' .
57+
'Generate a random string: openssl rand -base64 32'
58+
);
59+
}
60+
5261
return hash('sha256', $normalized . $pepper);
5362
}
5463

@@ -60,25 +69,43 @@ public static function exact(string $normalized, string $pepper): string
6069
* These prefix hashes can be used to implement fast "starts-with"
6170
* queries while maintaining cryptographic privacy.
6271
*
63-
* Example: "alex" with maxDepth=3 yields tokens for "a", "al", "ale".
72+
* Only prefixes at or above the minimum length (from config) are generated.
73+
* This prevents overly broad matches from very short search terms.
74+
*
75+
* Example: "alex" with maxDepth=4, minLength=2 yields tokens for "al", "ale", "alex".
76+
* (skips "a" because it's below minimum length)
6477
*
6578
* @param string $normalized
6679
* The normalized (lowercase, diacritic-free) string.
6780
* @param int $maxDepth
6881
* The maximum number of prefix characters to hash.
6982
* @param string $pepper
7083
* A secret application-level random string from configuration.
84+
* @param int $minLength
85+
* The minimum prefix length to generate (default: 1 for backwards compatibility).
7186
*
7287
* @return string[]
7388
* An array of hex-encoded SHA-256 prefix tokens.
89+
*
90+
* @throws \RuntimeException if pepper is empty
7491
*/
75-
public static function prefixes(string $normalized, int $maxDepth, string $pepper): array
92+
public static function prefixes(string $normalized, int $maxDepth, string $pepper, int $minLength = 1): array
7693
{
94+
if (empty($pepper)) {
95+
throw new \RuntimeException(
96+
'SEARCH_PEPPER is not configured. Set it in your .env file for security. ' .
97+
'Generate a random string: openssl rand -base64 32'
98+
);
99+
}
100+
77101
$out = [];
78102
$len = mb_strlen($normalized, 'UTF-8');
79103
$depth = min($maxDepth, $len);
80104

81-
for ($i = 1; $i <= $depth; $i++) {
105+
// Start from minimum length instead of 1
106+
$start = max(1, $minLength);
107+
108+
for ($i = $start; $i <= $depth; $i++) {
82109
$prefix = mb_substr($normalized, 0, $i, 'UTF-8');
83110
$out[] = hash('sha256', $prefix . $pepper);
84111
}

0 commit comments

Comments
 (0)