Storing the verification phone as a keyed HMAC fixed OTP lookups but broke every query that joined those tables back to the account, because phone_verification*.phone_number no longer holds the same value as driver.phone / passengers.phone. Six joins were affected, and four of them feed the `verified` flag that the rider and driver apps check at sign-in — so this was already failing under the current CBC mode, not only after a switch to GCM. Accounts now carry phone_key, computed exactly as otpPhoneKey() does, and the joins match on it. It is written at registration for both apps and populated for existing rows by the backfill. The backfill also covers the columns added for the remaining lookups: users.email_bidx/phone_bidx and driver.national_bidx, which were migrated but never populated, and honours a per-field prefix so phone_key reproduces otpPhoneKey's exact output. Insert column/value counts verified with a paren-aware parser after editing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
8.3 KiB
PHP
189 lines
8.3 KiB
PHP
<?php
|
|
/**
|
|
* scripts/migrate.php
|
|
*
|
|
* مُشغّل ترحيلات بسيط يعمل من سطر الأوامر داخل حاوية php.
|
|
* يستخدم نفس إعدادات الاتصال في core/Database — لا تحتاج phpMyAdmin ولا
|
|
* تمرير كلمة مرور القاعدة يدوياً.
|
|
*
|
|
* الاستخدام:
|
|
* php scripts/migrate.php --status # ماذا سيُطبَّق وماذا هو مطبَّق
|
|
* php scripts/migrate.php --dry-run # عرض العبارات دون تنفيذ
|
|
* php scripts/migrate.php # التطبيق
|
|
*
|
|
* كل خطوة idempotent: تُفحص حالة المخطط قبل التنفيذ، فإعادة التشغيل آمنة
|
|
* ولا تُنتج خطأ "Duplicate column".
|
|
*/
|
|
|
|
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';
|
|
|
|
$options = getopt('', ['dry-run', 'status']);
|
|
$dryRun = isset($options['dry-run']);
|
|
$status = isset($options['status']);
|
|
|
|
$con = Database::get('main');
|
|
|
|
/** هل يوجد العمود؟ */
|
|
function hasColumn(PDO $con, string $table, string $column): bool
|
|
{
|
|
$st = $con->prepare(
|
|
"SELECT COUNT(*) FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
|
|
);
|
|
$st->execute([$table, $column]);
|
|
return (int) $st->fetchColumn() > 0;
|
|
}
|
|
|
|
/** هل يوجد الفهرس؟ */
|
|
function hasIndex(PDO $con, string $table, string $index): bool
|
|
{
|
|
$st = $con->prepare(
|
|
"SELECT COUNT(*) FROM information_schema.STATISTICS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?"
|
|
);
|
|
$st->execute([$table, $index]);
|
|
return (int) $st->fetchColumn() > 0;
|
|
}
|
|
|
|
function tableExists(PDO $con, string $table): bool
|
|
{
|
|
$st = $con->prepare(
|
|
"SELECT COUNT(*) FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
|
|
);
|
|
$st->execute([$table]);
|
|
return (int) $st->fetchColumn() > 0;
|
|
}
|
|
|
|
// ── تعريف الترحيلات ──────────────────────────────────────────
|
|
// كلها إضافية: أعمدة جديدة تقبل NULL. لا تُعدَّل ولا تُحذف بيانات قائمة،
|
|
// ولا يقرأها أي كود قبل تعبئتها، فتطبيقها على قاعدة تعمل بلا أثر.
|
|
$columns = [
|
|
['driver', 'phone_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف'"],
|
|
['driver', 'email_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد'"],
|
|
['driver', 'name_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم بعد التطبيع'"],
|
|
['passengers', 'phone_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف'"],
|
|
['passengers', 'email_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد'"],
|
|
['passengers', 'name_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم بعد التطبيع'"],
|
|
['adminUser', 'phone_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف'"],
|
|
['adminUser', 'email_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد'"],
|
|
// نسخة مشفّرة قابلة للاسترجاع من رقم الهاتف في جداول التحقق.
|
|
// عمود phone_number صار يحمل مفتاح بحث أحادي الاتجاه (HMAC)، فلا يمكن
|
|
// استرجاع الرقم منه — وخدمة العملاء تحتاجه لمتابعة من طلب رمزاً ولم
|
|
// يُكمل تسجيله.
|
|
['phone_verification', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"],
|
|
['phone_verification_passenger', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"],
|
|
['phone_verification_service', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"],
|
|
['token_verification', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"],
|
|
['token_verification_driver', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"],
|
|
|
|
// مفتاح ربط جداول التحقق: نفس صيغة otpPhoneKey، حتى يمكن ربط
|
|
// phone_verification*.phone_number بصاحب الحساب دون مقارنة نصوص مشفّرة.
|
|
['driver', 'phone_key', "VARCHAR(80) NULL DEFAULT NULL COMMENT 'مفتاح ربط جداول التحقق'"],
|
|
['passengers', 'phone_key', "VARCHAR(80) NULL DEFAULT NULL COMMENT 'مفتاح ربط جداول التحقق'"],
|
|
|
|
// بقية الجداول التي يُبحث فيها بحقل مشفّر
|
|
['users', 'email_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC لبريد موظف الخدمة'"],
|
|
['users', 'phone_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC لهاتف موظف الخدمة'"],
|
|
['driver', 'national_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للرقم الوطني'"],
|
|
|
|
// أعمدة موافقات المشرفين المفقودة في هذا النشر
|
|
['adminUser', 'status', "VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected'"],
|
|
['adminUser', 'approved_by', "VARCHAR(32) NULL DEFAULT NULL"],
|
|
['adminUser', 'approved_at', "TIMESTAMP NULL DEFAULT NULL"],
|
|
];
|
|
|
|
$indexes = [
|
|
['driver', 'idx_driver_phone_bidx', 'phone_bidx'],
|
|
['driver', 'idx_driver_email_bidx', 'email_bidx'],
|
|
['driver', 'idx_driver_name_bidx', 'name_bidx'],
|
|
['passengers', 'idx_passengers_phone_bidx', 'phone_bidx'],
|
|
['passengers', 'idx_passengers_email_bidx', 'email_bidx'],
|
|
['passengers', 'idx_passengers_name_bidx', 'name_bidx'],
|
|
['adminUser', 'idx_adminuser_phone_bidx', 'phone_bidx'],
|
|
['adminUser', 'idx_adminuser_email_bidx', 'email_bidx'],
|
|
['users', 'idx_users_email_bidx', 'email_bidx'],
|
|
['users', 'idx_users_phone_bidx', 'phone_bidx'],
|
|
['driver', 'idx_driver_national_bidx', 'national_bidx'],
|
|
['driver', 'idx_driver_phone_key', 'phone_key'],
|
|
['passengers', 'idx_passengers_phone_key', 'phone_key'],
|
|
];
|
|
|
|
$applied = 0;
|
|
$skipped = 0;
|
|
$missing = [];
|
|
|
|
echo "── Siro schema migration ──\n";
|
|
echo "database: " . $con->query('SELECT DATABASE()')->fetchColumn() . "\n";
|
|
if ($dryRun) echo "mode: DRY RUN (nothing will be executed)\n";
|
|
if ($status) echo "mode: STATUS ONLY\n";
|
|
echo "\n";
|
|
|
|
foreach ($columns as [$table, $column, $definition]) {
|
|
if (!tableExists($con, $table)) {
|
|
$missing[$table] = true;
|
|
continue;
|
|
}
|
|
if (hasColumn($con, $table, $column)) {
|
|
echo " = $table.$column already present\n";
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$sql = "ALTER TABLE `$table` ADD COLUMN `$column` $definition";
|
|
if ($status || $dryRun) {
|
|
echo " + would run: $sql\n";
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$con->exec($sql);
|
|
echo " + $table.$column added\n";
|
|
$applied++;
|
|
} catch (PDOException $e) {
|
|
echo " ✘ $table.$column failed: " . $e->getMessage() . "\n";
|
|
}
|
|
}
|
|
|
|
foreach ($indexes as [$table, $index, $column]) {
|
|
if (!tableExists($con, $table) || !hasColumn($con, $table, $column)) continue;
|
|
if (hasIndex($con, $table, $index)) {
|
|
echo " = $table.$index already present\n";
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$sql = "ALTER TABLE `$table` ADD INDEX `$index` (`$column`)";
|
|
if ($status || $dryRun) {
|
|
echo " + would run: $sql\n";
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$con->exec($sql);
|
|
echo " + $table.$index created\n";
|
|
$applied++;
|
|
} catch (PDOException $e) {
|
|
echo " ✘ $table.$index failed: " . $e->getMessage() . "\n";
|
|
}
|
|
}
|
|
|
|
foreach (array_keys($missing) as $table) {
|
|
echo " ! table `$table` does not exist in this database — skipped\n";
|
|
}
|
|
|
|
echo "\napplied: $applied · already in place: $skipped\n";
|
|
|
|
if (!$status && !$dryRun && $applied > 0) {
|
|
echo "\nNext: populate the indexes\n";
|
|
echo " php scripts/backfill_blind_index.php --dry-run\n";
|
|
echo " php scripts/backfill_blind_index.php\n";
|
|
}
|