Storing the verification phone as a keyed HMAC made OTP lookups independent of the encryption mode, but the hash is one-way — and customer service reads those same rows to chase people who requested a code and never finished registering. That workflow would have lost the number entirely. The verification tables now carry both forms: phone_number holds the lookup key, and a new phone_enc column holds the encrypted number, which is decryptable when a human needs to call. The two follow-up queries also compared the verification row against the driver/passengers tables and the notes tables by matching ciphertext, which only ever worked because encryption was deterministic. Under GCM every number would have looked unregistered and every note would have disappeared. Both now read the number from phone_enc and match on normalised plaintext, so they are correct under either mode. Rows written before phone_enc existed are skipped rather than shown without a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.1 KiB
PHP
174 lines
7.1 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 'الهاتف مشفّراً للاسترجاع'"],
|
|
|
|
// أعمدة موافقات المشرفين المفقودة في هذا النشر
|
|
['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'],
|
|
];
|
|
|
|
$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";
|
|
}
|