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
39 changes: 37 additions & 2 deletions src/Console/RebuildIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,42 @@ public function handle(): int
$query = $class::query();

$count = 0;
$encrypted = 0;

$query->chunk($chunk, function ($models) use (&$count) {
$query->chunk($chunk, function ($models) use (&$count, &$encrypted) {
foreach ($models as $model) {
// Check if model has encrypted casts and ensure data is encrypted
if (method_exists($model, 'getCasts')) {
$casts = $model->getCasts();
$needsSave = false;

foreach ($casts as $field => $cast) {
if (str_contains(strtolower($cast), 'encrypted')) {
// Get the raw value from database (bypassing accessors)
$attributes = $model->getAttributes();
$rawValue = $attributes[$field] ?? null;

// Check if value exists and is not already encrypted
// Encrypted values start with 'eyJpdiI' (base64 of '{"iv"')
if ($rawValue && !str_starts_with($rawValue, 'eyJpdiI')) {
// Value is not encrypted, encrypt it now
$decrypted = $rawValue; // Value is already decrypted in DB
$model->setAttribute($field, $decrypted); // This will encrypt via cast
$needsSave = true;
$encrypted++;
}
}
}

// Save if any fields were re-encrypted
if ($needsSave) {
$model->save();
}
}

// Update search index
if (method_exists($model, 'updateSearchIndex')) {
$model->updateSearchIndex(); // <-- FIXED
$model->updateSearchIndex();
}
$count++;
}
Expand All @@ -87,6 +118,10 @@ public function handle(): int
$this->newLine();
$this->info("Rebuilt index for {$count} records of {$class}.");

if ($encrypted > 0) {
$this->info("Encrypted {$encrypted} unencrypted field(s) during rebuild.");
}

return self::SUCCESS;
}
}
101 changes: 101 additions & 0 deletions src/Traits/HasEncryptedSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,107 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string
});
}

/**
* Scope: search for a term across multiple fields using OR logic.
*
* @param Builder $query
* @param array<int, string> $fields
* @param string $term
* @param string $type Either 'exact' or 'prefix'
* @return Builder
*/
public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder
{
if ($type === 'exact') {
return $this->scopeEncryptedExactMulti($query, $fields, $term);
}

return $this->scopeEncryptedPrefixMulti($query, $fields, $term);
}

/**
* Scope: search for multiple field-term pairs using AND logic.
*
* All specified field-term pairs must match for a record to be returned.
*
* @param Builder $query
* @param array<string, string> $fieldTerms Associative array of field => term pairs
* @param string $type Either 'exact' or 'prefix'
* @return Builder
*/
public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder
{
if (empty($fieldTerms)) {
return $query->whereRaw('1=0');
}

$pepper = (string) config('encrypted-search.search_pepper', '');
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
$useElastic = config('encrypted-search.elasticsearch.enabled', false);

// Build conditions for each field-term pair
foreach ($fieldTerms as $field => $term) {
$normalized = Normalizer::normalize($term);

if (!$normalized) {
return $query->whereRaw('1=0');
}

if ($type === 'prefix') {
// Check minimum length for prefix searches
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
return $query->whereRaw('1=0');
}

$tokens = Tokens::prefixes(
$normalized,
(int) config('encrypted-search.max_prefix_depth', 6),
$pepper,
$minLength
);

if (empty($tokens)) {
return $query->whereRaw('1=0');
}

// AND logic: intersect model IDs for each field-term pair
if ($useElastic) {
$modelIds = $this->searchElasticsearch($field, $tokens, 'prefix');
$query->whereIn($this->getQualifiedKeyName(), $modelIds);
} else {
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens) {
$sub->select('model_id')
->from('encrypted_search_index')
->where('model_type', static::class)
->where('field', $field)
->where('type', 'prefix')
->whereIn('token', $tokens);
});
}
} else {
// Exact match
$token = Tokens::exact($normalized, $pepper);

// AND logic: intersect model IDs for each field-term pair
if ($useElastic) {
$modelIds = $this->searchElasticsearch($field, $token, 'exact');
$query->whereIn($this->getQualifiedKeyName(), $modelIds);
} else {
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $token) {
$sub->select('model_id')
->from('encrypted_search_index')
->where('model_type', static::class)
->where('field', $field)
->where('type', 'exact')
->where('token', $token);
});
}
}
}

return $query;
}

/**
* Check if a field has an encrypted cast.
*
Expand Down