Add blind-index search layer; fix captain detail 200-with-empty-body
Searching encrypted columns currently works only because encryptData() is AES-CBC with a fixed IV, i.e. deterministic. That determinism is what leaks equality and shared prefixes, and it is why moving storage to AES-GCM would break every lookup. This separates the two concerns. - core/Security/BlindIndex.php: HMAC-SHA256 over a normalised value, keyed by a secret pepper. Phone numbers have a small keyspace, so a bare SHA-256 would be reversible by enumeration; the pepper lives in the environment, not the database. The scope string includes table and field so the same number does not produce a matching index across tables. Normalisation unifies local/international phone forms, lowercases emails and folds Arabic alef/ya/ta-marbuta and diacritics for names. - migrations/: nullable *_bidx columns plus indexes, and the missing adminUser.status/approved_by/approved_at columns that admin approvals need. - scripts/backfill_blind_index.php: restartable, batched, --dry-run capable, touches only index columns. - Admin lookups by phone/email now match the index, keeping the old ciphertext comparison in the same query so search keeps working until the backfill runs. bootstrap exposes $blindIndex as null when no pepper is configured. Also: AdminCaptain/getCaptainDetailsById.php selected driver.education, a column absent from this schema. The PDOException was uncaught, so the client received an empty body with HTTP 200 — the "non-JSON response" seen when opening a captain. It now omits the column, catches the error, reports it as JSON, and requires an admin role. Console: opening any sidebar section refetches its data instead of showing what was loaded when the console started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81ee2acefd
commit
6802026dbd
@@ -5,6 +5,15 @@ $driver_id = filterRequest("driver_id");
|
||||
$driverEmail = $encryptionHelper->encryptData(filterRequest("driverEmail"));
|
||||
$driverPhone = $encryptionHelper->encryptData(filterRequest("driverPhone"));
|
||||
|
||||
|
||||
/**
|
||||
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
|
||||
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', filterRequest("driverEmail")) : null;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', filterRequest("driverPhone")) : null;
|
||||
|
||||
$sql = "SELECT
|
||||
`driver`.`id`,
|
||||
`driver`.`phone`,
|
||||
@@ -53,6 +62,8 @@ $sql = "SELECT
|
||||
) AS passengerToken
|
||||
FROM `driver`
|
||||
WHERE `driver`.`email` = :email OR `driver`.`phone` = :phone OR `driver`.`id` = :id
|
||||
OR (:email_bidx IS NOT NULL AND `driver`.`email_bidx` = :email_bidx)
|
||||
OR (:phone_bidx IS NOT NULL AND `driver`.`phone_bidx` = :phone_bidx)
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT 10
|
||||
";
|
||||
@@ -61,6 +72,8 @@ $stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":email", $driverEmail);
|
||||
$stmt->bindParam(":phone", $driverPhone);
|
||||
$stmt->bindParam(":id", $driver_id);
|
||||
$stmt->bindParam(":email_bidx", $emailBidx);
|
||||
$stmt->bindParam(":phone_bidx", $phoneBidx);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// تشفير driver_id قبل استخدامه في SQL
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$driver_id = filterRequest("driver_id");
|
||||
|
||||
if (empty($driver_id)) {
|
||||
jsonError("driver_id is required", 400);
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
`driver`.`id`,
|
||||
`driver`.`phone`,
|
||||
@@ -14,7 +23,6 @@ $sql = "SELECT
|
||||
`driver`.`site`,
|
||||
`driver`.`first_name`,
|
||||
`driver`.`last_name`,
|
||||
`driver`.`education`,
|
||||
`driver`.`employmentType`,
|
||||
`driver`.`maritalStatus`,
|
||||
`driver`.`created_at`,
|
||||
@@ -59,14 +67,23 @@ WHERE `driver`.`id` = :driver_id
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT 10";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':driver_id', $driver_id);
|
||||
$stmt->execute();
|
||||
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
try {
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':driver_id', $driver_id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
// بلا هذا الالتقاط كان الاستثناء يُنهي السكربت فيصل للعميل جسم فارغ
|
||||
// بحالة HTTP 200، فيظهر كـ "رد غير JSON".
|
||||
error_log("[getCaptainDetailsById] " . $e->getMessage());
|
||||
jsonError("Could not read the captain record: " . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// فك تشفير الحقول الحساسة بعد الجلب
|
||||
foreach ($result as &$row) {
|
||||
foreach (['phone','email','gender','birthdate','site','first_name','last_name','employmentType','maritalStatus'] as $f) {
|
||||
if (!array_key_exists($f, $row)) $row[$f] = null;
|
||||
}
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
$row['email'] = $encryptionHelper->decryptData($row['email']);
|
||||
$row['gender'] = $encryptionHelper->decryptData($row['gender']);
|
||||
@@ -74,7 +91,6 @@ foreach ($result as &$row) {
|
||||
$row['site'] = $encryptionHelper->decryptData($row['site']);
|
||||
$row['first_name'] = $encryptionHelper->decryptData($row['first_name']);
|
||||
$row['last_name'] = $encryptionHelper->decryptData($row['last_name']);
|
||||
$row['education'] = $encryptionHelper->decryptData($row['education']);
|
||||
$row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']);
|
||||
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']);
|
||||
}
|
||||
|
||||
@@ -9,18 +9,34 @@ if (empty($phone)) {
|
||||
}
|
||||
|
||||
try {
|
||||
// تشفير الرقم المدخل للبحث
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
/**
|
||||
* البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ
|
||||
* ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM.
|
||||
*
|
||||
* يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل
|
||||
* scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$driver = null;
|
||||
|
||||
// احضار كل الأعمدة باستثناء كلمة المرور
|
||||
$sql = "SELECT *
|
||||
FROM driver
|
||||
WHERE phone = :phone
|
||||
LIMIT 1";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':phone' => $encPhone]);
|
||||
if ($blindIndex) {
|
||||
$bidx = $blindIndex->index('driver.phone', $phone);
|
||||
if ($bidx) {
|
||||
$stmt = $con->prepare("SELECT * FROM driver WHERE phone_bidx = :bidx LIMIT 1");
|
||||
$stmt->execute([':bidx' => $bidx]);
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$driver) {
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
$stmt = $con->prepare("SELECT * FROM driver WHERE phone = :phone LIMIT 1");
|
||||
$stmt->execute([':phone' => $encPhone]);
|
||||
}
|
||||
|
||||
if (!$driver) {
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if ($driver) {
|
||||
// ✅ الحقول المشفرة اللي لازم تنفك:
|
||||
|
||||
@@ -5,6 +5,15 @@ $passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail")
|
||||
$passengerId = filterRequest("passengerId");
|
||||
$passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone"));
|
||||
|
||||
|
||||
/**
|
||||
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
|
||||
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', filterRequest("passengerEmail")) : null;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', filterRequest("passengerphone")) : null;
|
||||
|
||||
$sql = "SELECT
|
||||
`passengers`.`id`,
|
||||
`passengers`.`phone`,
|
||||
@@ -59,12 +68,16 @@ FROM
|
||||
`passengers`
|
||||
WHERE
|
||||
passengers.email = :email OR passengers.phone = :phone OR passengers.id = :id
|
||||
OR (:email_bidx IS NOT NULL AND passengers.email_bidx = :email_bidx)
|
||||
OR (:phone_bidx IS NOT NULL AND passengers.phone_bidx = :phone_bidx)
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":email", $passengerEmail);
|
||||
$stmt->bindParam(":phone", $passengerphone);
|
||||
$stmt->bindParam(":id", $passengerId);
|
||||
$stmt->bindParam(":email_bidx", $emailBidx);
|
||||
$stmt->bindParam(":phone_bidx", $phoneBidx);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Security/BlindIndex.php
|
||||
*
|
||||
* فهرس أعمى للبحث فوق حقول مشفّرة.
|
||||
*
|
||||
* المشكلة: التشفير الآمن (AES-GCM) عشوائي — نفس النص ينتج تشفيراً مختلفاً في
|
||||
* كل مرة، فلا يمكن البحث بمقارنة النص المشفّر. والحل القديم (CBC بـ IV ثابت)
|
||||
* يجعل التشفير حتمياً فينجح البحث، لكنه يسرّب المساواة والبادئات المشتركة.
|
||||
*
|
||||
* الحل: نفصل التخزين عن البحث.
|
||||
* - التخزين: AES-GCM عشوائي (لا يسرّب شيئاً).
|
||||
* - البحث: عمود إضافي يحمل HMAC-SHA256 حتمياً للقيمة بعد تطبيعها.
|
||||
*
|
||||
* لماذا HMAC وليس sha256 عارياً؟ لأن مساحة أرقام الهواتف صغيرة (ملايين
|
||||
* قليلة) — جدول عكسي لكل الأرقام يُبنى في ثوانٍ. المفتاح السرّي (pepper)
|
||||
* المخزَّن في البيئة وحده يمنع ذلك، فمن يسرق قاعدة البيانات لا يملكه.
|
||||
*/
|
||||
|
||||
final class BlindIndex
|
||||
{
|
||||
private string $pepper;
|
||||
|
||||
public function __construct(?string $pepper = null)
|
||||
{
|
||||
$pepper = $pepper ?: (getenv('BLIND_INDEX_PEPPER') ?: '');
|
||||
if ($pepper === '') {
|
||||
throw new RuntimeException(
|
||||
'BLIND_INDEX_PEPPER is not set. Generate one with: openssl rand -hex 32'
|
||||
);
|
||||
}
|
||||
$this->pepper = $pepper;
|
||||
}
|
||||
|
||||
/**
|
||||
* يحسب الفهرس لقيمة داخل حقل محدد.
|
||||
*
|
||||
* $scope يشمل الجدول والحقل (مثل "driver.phone") عمداً: بدونه يكون فهرس
|
||||
* نفس الرقم متطابقاً في جدول السائقين والركاب، فيستطيع من يقرأ القاعدة
|
||||
* ربط الحسابات ببعضها دون فك أي تشفير.
|
||||
*/
|
||||
public function index(string $scope, ?string $value): ?string
|
||||
{
|
||||
$normalized = self::normalize($scope, $value);
|
||||
if ($normalized === null || $normalized === '') {
|
||||
return null;
|
||||
}
|
||||
return hash_hmac('sha256', $scope . ':' . $normalized, $this->pepper);
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرس مبتور للبحث الجزئي (مثل الأسماء).
|
||||
*
|
||||
* البتر مقصود: يُنتج تطابقات كاذبة تُصفّى بعد فك التشفير، وهذه الضبابية
|
||||
* هي ما يمنع استخدام الفهرس نفسه في تحليل التكرارات.
|
||||
*/
|
||||
public function bucket(string $scope, ?string $value, int $length = 8): ?string
|
||||
{
|
||||
$full = $this->index($scope, $value);
|
||||
return $full === null ? null : substr($full, 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* التطبيع قبل الحساب — بدونه يُنتج 0791234567 و+962791234567 فهرسين
|
||||
* مختلفين ويفشل البحث.
|
||||
*/
|
||||
public static function normalize(string $scope, ?string $value): ?string
|
||||
{
|
||||
if ($value === null) return null;
|
||||
$value = trim($value);
|
||||
if ($value === '') return null;
|
||||
|
||||
if (str_contains($scope, 'phone')) {
|
||||
$digits = preg_replace('/\D+/', '', $value);
|
||||
// توحيد الصيغة المحلية والدولية على شكل واحد
|
||||
$digits = preg_replace('/^00/', '', $digits);
|
||||
if (str_starts_with($digits, '0')) {
|
||||
$cc = getenv('DEFAULT_COUNTRY_CODE') ?: '962';
|
||||
$digits = $cc . substr($digits, 1);
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
if (str_contains($scope, 'email')) {
|
||||
return mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
// الأسماء: توحيد حالة الأحرف والمسافات، وتوحيد أشكال الألف والياء
|
||||
// والتاء المربوطة العربية حتى لا يتوقف البحث على شكل الكتابة.
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
$value = preg_replace('/\s+/u', ' ', $value);
|
||||
$value = str_replace(
|
||||
['أ', 'إ', 'آ', 'ٱ', 'ى', 'ة', 'ؤ', 'ئ'],
|
||||
['ا', 'ا', 'ا', 'ا', 'ي', 'ه', 'و', 'ي'],
|
||||
$value
|
||||
);
|
||||
// إزالة التشكيل
|
||||
$value = preg_replace('/[\x{064B}-\x{0652}\x{0640}]/u', '', $value);
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,17 @@ try {
|
||||
|
||||
// 5. تحميل الـ Services الأساسية
|
||||
require_once __DIR__ . '/Security/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/Security/BlindIndex.php';
|
||||
|
||||
// فهرس البحث الأعمى — اختياري: إن لم يُضبط BLIND_INDEX_PEPPER تبقى نقاط
|
||||
// البحث تعمل بأسلوبها القديم بدل أن تفشل.
|
||||
$blindIndex = null;
|
||||
try {
|
||||
$blindIndex = new BlindIndex();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[BlindIndex] disabled: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/Database/Database.php';
|
||||
require_once __DIR__ . '/Auth/RateLimiter.php';
|
||||
require_once __DIR__ . '/Auth/JwtService.php';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ============================================================
|
||||
-- Blind index columns for searching encrypted fields
|
||||
-- 2026-07-25
|
||||
--
|
||||
-- تُضاف أعمدة بحث حتمية (HMAC) بجانب الأعمدة المشفّرة، حتى يمكن نقل التخزين
|
||||
-- إلى AES-GCM العشوائي دون فقدان القدرة على البحث.
|
||||
--
|
||||
-- آمنة للتشغيل على قاعدة تعمل: كل الأعمدة NULL افتراضياً ولا يقرأها أي كود
|
||||
-- قبل تشغيل سكربت التعبئة.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `driver`
|
||||
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
|
||||
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
|
||||
ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع',
|
||||
ADD INDEX `idx_driver_phone_bidx` (`phone_bidx`),
|
||||
ADD INDEX `idx_driver_email_bidx` (`email_bidx`),
|
||||
ADD INDEX `idx_driver_name_bidx` (`name_bidx`);
|
||||
|
||||
ALTER TABLE `passengers`
|
||||
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
|
||||
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
|
||||
ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع',
|
||||
ADD INDEX `idx_passengers_phone_bidx` (`phone_bidx`),
|
||||
ADD INDEX `idx_passengers_email_bidx` (`email_bidx`),
|
||||
ADD INDEX `idx_passengers_name_bidx` (`name_bidx`);
|
||||
|
||||
ALTER TABLE `adminUser`
|
||||
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
|
||||
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
|
||||
ADD INDEX `idx_adminuser_phone_bidx` (`phone_bidx`),
|
||||
ADD INDEX `idx_adminuser_email_bidx` (`email_bidx`);
|
||||
|
||||
-- عمود status مفقود في هذا النشر، وبدونه لا يمكن تتبّع موافقات المشرفين
|
||||
-- (Admin/Staff/pending.php و auth/approve_admin.php يعتمدان عليه).
|
||||
-- القيمة الافتراضية 'active' مقصودة حتى لا تُقفل الحسابات القائمة خارج النظام.
|
||||
ALTER TABLE `adminUser`
|
||||
ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected',
|
||||
ADD COLUMN `approved_by` VARCHAR(32) NULL DEFAULT NULL,
|
||||
ADD COLUMN `approved_at` TIMESTAMP NULL DEFAULT NULL;
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* scripts/backfill_blind_index.php
|
||||
*
|
||||
* يملأ أعمدة الفهرس الأعمى للسجلات الموجودة.
|
||||
*
|
||||
* الاستخدام:
|
||||
* php backfill_blind_index.php # تشغيل فعلي
|
||||
* php backfill_blind_index.php --dry-run # عرض ما سيحدث دون كتابة
|
||||
* php backfill_blind_index.php --table=driver --batch=200
|
||||
*
|
||||
* آمن للتشغيل والخدمة تعمل:
|
||||
* - لا يقرأ ولا يكتب أي عمود مشفّر، يكتب أعمدة الفهرس فقط.
|
||||
* - يعمل على دفعات مع فاصل قصير حتى لا يضغط القاعدة.
|
||||
* - قابل لإعادة التشغيل: يتخطى الصفوف المملوءة ما لم يُمرَّر --force.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit("This script runs from the command line only.\n");
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../core/Security/BlindIndex.php';
|
||||
|
||||
$options = getopt('', ['dry-run', 'force', 'table::', 'batch::']);
|
||||
$dryRun = isset($options['dry-run']);
|
||||
$force = isset($options['force']);
|
||||
$only = $options['table'] ?? null;
|
||||
$batch = max(50, (int) ($options['batch'] ?? 500));
|
||||
|
||||
/** @var EncryptionHelper $encryptionHelper */
|
||||
global $encryptionHelper;
|
||||
|
||||
try {
|
||||
$blind = new BlindIndex();
|
||||
} catch (RuntimeException $e) {
|
||||
exit("✘ " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
$con = Database::get('main');
|
||||
|
||||
$targets = [
|
||||
'driver' => [
|
||||
'phone_bidx' => ['scope' => 'driver.phone', 'columns' => ['phone']],
|
||||
'email_bidx' => ['scope' => 'driver.email', 'columns' => ['email']],
|
||||
'name_bidx' => ['scope' => 'driver.name', 'columns' => ['first_name', 'last_name']],
|
||||
],
|
||||
'passengers' => [
|
||||
'phone_bidx' => ['scope' => 'passengers.phone', 'columns' => ['phone']],
|
||||
'email_bidx' => ['scope' => 'passengers.email', 'columns' => ['email']],
|
||||
'name_bidx' => ['scope' => 'passengers.name', 'columns' => ['first_name', 'last_name']],
|
||||
],
|
||||
'adminUser' => [
|
||||
'phone_bidx' => ['scope' => 'adminUser.phone', 'columns' => ['phone']],
|
||||
'email_bidx' => ['scope' => 'adminUser.email', 'columns' => ['email']],
|
||||
],
|
||||
];
|
||||
|
||||
echo $dryRun ? "── DRY RUN — nothing will be written ──\n" : "── Backfilling blind indexes ──\n";
|
||||
|
||||
foreach ($targets as $table => $fields) {
|
||||
if ($only && $only !== $table) continue;
|
||||
|
||||
echo "\n[$table]\n";
|
||||
|
||||
$sourceColumns = [];
|
||||
foreach ($fields as $spec) {
|
||||
foreach ($spec['columns'] as $c) $sourceColumns[$c] = true;
|
||||
}
|
||||
$select = 'id, ' . implode(', ', array_keys($sourceColumns));
|
||||
|
||||
$where = $force ? '' : ' WHERE ' . implode(' OR ', array_map(
|
||||
fn($f) => "`$f` IS NULL",
|
||||
array_keys($fields)
|
||||
));
|
||||
|
||||
try {
|
||||
$rows = $con->query("SELECT $select FROM `$table`$where")->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
echo " ✘ skipped: " . $e->getMessage() . "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$total = count($rows);
|
||||
echo " rows to process: $total\n";
|
||||
if ($total === 0) continue;
|
||||
|
||||
$updated = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach (array_chunk($rows, $batch) as $chunk) {
|
||||
if (!$dryRun) $con->beginTransaction();
|
||||
|
||||
foreach ($chunk as $row) {
|
||||
$set = [];
|
||||
$params = [':id' => $row['id']];
|
||||
|
||||
foreach ($fields as $column => $spec) {
|
||||
// فك التشفير لقراءة القيمة الأصلية — القيمة المخزَّنة لا تتغير.
|
||||
$parts = [];
|
||||
foreach ($spec['columns'] as $src) {
|
||||
$plain = $encryptionHelper->decryptData($row[$src] ?? null);
|
||||
if ($plain === false) {
|
||||
$failed++;
|
||||
$parts = [];
|
||||
break;
|
||||
}
|
||||
if ($plain !== '') $parts[] = $plain;
|
||||
}
|
||||
if (!$parts) continue;
|
||||
|
||||
$value = implode(' ', $parts);
|
||||
$index = $blind->index($spec['scope'], $value);
|
||||
if ($index === null) continue;
|
||||
|
||||
$set[] = "`$column` = :$column";
|
||||
$params[":$column"] = $index;
|
||||
}
|
||||
|
||||
if (!$set) continue;
|
||||
|
||||
if ($dryRun) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt = $con->prepare("UPDATE `$table` SET " . implode(', ', $set) . " WHERE id = :id");
|
||||
$stmt->execute($params);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
if (!$dryRun) $con->commit();
|
||||
usleep(50_000); // نفس متعمّد حتى لا تُحتكر القاعدة أثناء الخدمة
|
||||
echo " … $updated/$total\n";
|
||||
}
|
||||
|
||||
echo " ✔ indexed: $updated" . ($failed ? " (undecryptable values skipped: $failed)" : '') . "\n";
|
||||
}
|
||||
|
||||
echo "\nDone." . ($dryRun ? " (dry run — re-run without --dry-run to apply)" : '') . "\n";
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- ?v= must be bumped whenever css/main.css or js/app.js changes: the files
|
||||
are served straight off a bind mount, so without it browsers keep
|
||||
running the previously cached build after a deploy. -->
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-6">
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-7">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -609,6 +609,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js?v=2026-07-25-6"></script>
|
||||
<script src="js/app.js?v=2026-07-25-7"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// Bump together with the ?v= query in index.html. Shown in the UI and in the
|
||||
// diagnostics report so "the deploy did nothing" can be answered with a fact
|
||||
// rather than a guess about caching.
|
||||
const BUILD = '2026-07-25-6';
|
||||
const BUILD = '2026-07-25-7';
|
||||
|
||||
// ── Localisation ─────────────────────────────────────────────────────────
|
||||
// Arabic is the operators' language; English is kept because several screens
|
||||
@@ -2815,15 +2815,44 @@
|
||||
if (window.innerWidth <= 992) el.sidebar.classList.remove('open');
|
||||
redrawCharts();
|
||||
|
||||
const mod = MODULES.find((m) => m.id === item.dataset.module);
|
||||
if (mod && session) loadModule(mod);
|
||||
// Opening a section always refetches it: an operator switching to a
|
||||
// list expects what the database holds now, not what it held when the
|
||||
// console was first opened.
|
||||
if (session) refreshView(item.dataset.view, item.dataset.module);
|
||||
});
|
||||
});
|
||||
el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open'));
|
||||
}
|
||||
|
||||
|
||||
// Maps a sidebar entry to the loader that owns its data.
|
||||
const VIEW_LOADERS = {
|
||||
dashboardView: () => { loadStats().catch(() => {}); loadRidesTrend().catch(() => {}); },
|
||||
ridesView: () => loadRides().catch(() => {}),
|
||||
driversView: () => loadDrivers().catch(() => {}),
|
||||
passengersView: () => loadPassengers().catch(() => {}),
|
||||
financialsView: () => loadStats().catch(() => {}),
|
||||
complaintsView: () => loadStats().catch(() => {}),
|
||||
approvalsView: () => loadApprovals().catch(() => {}),
|
||||
systemView: () => renderSessionInfo(),
|
||||
};
|
||||
|
||||
function refreshView(viewId, moduleId) {
|
||||
const mod = MODULES.find((m) => m.id === moduleId);
|
||||
if (mod) {
|
||||
loadModule(mod, true); // force: bypass the loaded-once cache
|
||||
return;
|
||||
}
|
||||
VIEW_LOADERS[viewId]?.();
|
||||
}
|
||||
|
||||
function setupDataEvents() {
|
||||
el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); });
|
||||
el.refreshBtn?.addEventListener('click', () => {
|
||||
if (!session) return;
|
||||
const active = document.querySelector('.nav-item.active');
|
||||
if (active) refreshView(active.dataset.view, active.dataset.module);
|
||||
else loadEverything();
|
||||
});
|
||||
$('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar'));
|
||||
el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user