From db4ca7dd7a02fb7b641e737fefff7a8966b66673 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 02:00:14 +0300 Subject: [PATCH] Fix dashbord.php parse error; require super_admin on pricing and crypto tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hotfix: a comment added to the dashboard SQL contained double quotes inside the double-quoted PHP string, terminating it and making dashbord.php fail to parse. Production was returning a parse error for every dashboard request. Authorisation gaps closed — connect.php only proves a token is valid, it does not check what the caller is allowed to do: - Admin/ggg.php decrypts any database field and was authorised solely by an admin phone number sent in the request body. Anyone who knew a listed number could decrypt platform data without signing in. It now runs behind connect.php, requires super_admin, keeps the phone list as a second factor, and records every use. - ride/kazan/update.php, kazan/add.php and ride/promo/{add,update,delete}.php changed live pricing and discount codes with no role check at all, so any valid token — including a driver's or passenger's — could rewrite the fare table. All now require super_admin. Staff/pending.php: adminUser has no `status` column in this deployment, so the query failed with an opaque "unavailable". It now checks for the column and reports the actual reason. Console: Kazan tariff editor for super admins — sends only changed fields, shows an old → new confirmation before saving, and stays read-only with an explanatory notice for plain admins. Co-Authored-By: Claude Opus 5 --- backend/Admin/Staff/pending.php | 23 ++++- backend/Admin/dashbord.php | 2 +- backend/Admin/ggg.php | 35 ++++++- backend/ride/kazan/add.php | 12 +++ backend/ride/kazan/update.php | 12 +++ backend/ride/promo/add.php | 12 +++ backend/ride/promo/delete.php | 12 +++ backend/ride/promo/update.php | 12 +++ dashboard/siro-admin/css/main.css | 38 ++++++++ dashboard/siro-admin/js/app.js | 153 ++++++++++++++++++++++++++++-- 10 files changed, 299 insertions(+), 12 deletions(-) diff --git a/backend/Admin/Staff/pending.php b/backend/Admin/Staff/pending.php index 13b8d76e..239c7172 100644 --- a/backend/Admin/Staff/pending.php +++ b/backend/Admin/Staff/pending.php @@ -18,7 +18,28 @@ $sources = []; // كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل // الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً. +/** + * بعض عمليات النشر أنشأت adminUser بلا عمود status (انظر schema_primary.sql)، + * وعندها لا يمكن تمييز الحسابات المعلقة أصلاً. نفحص العمود أولاً لنُرجع سبباً + * واضحاً بدل فشل عام. + */ +function columnExists(PDO $con, string $table, string $column): bool +{ + try { + $stmt = $con->prepare("SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"); + $stmt->execute([$table, $column]); + return (int) $stmt->fetchColumn() > 0; + } catch (Throwable $e) { + return false; + } +} + try { + if (!columnExists($con, 'adminUser', 'status')) { + throw new RuntimeException("adminUser.status column is missing — admin approvals cannot be tracked until it is added."); + } + $stmt1 = $con->query("SELECT id, name, phone, role, created_at, 'admin' as type FROM adminUser WHERE status = 'pending'"); $admins = $stmt1->fetchAll(PDO::FETCH_ASSOC); @@ -32,7 +53,7 @@ try { $sources['admins'] = 'ok'; } catch (Throwable $e) { error_log("[Staff Pending] adminUser query failed: " . $e->getMessage()); - $sources['admins'] = 'unavailable'; + $sources['admins'] = 'unavailable: ' . $e->getMessage(); } try { diff --git a/backend/Admin/dashbord.php b/backend/Admin/dashbord.php index 92ec9ec2..ea56b6ae 100644 --- a/backend/Admin/dashbord.php +++ b/backend/Admin/dashbord.php @@ -30,7 +30,7 @@ SELECT -- إحصائيات وقت ومسافة الرحلات -- تُستثنى الفروق السالبة (رحلات سجّلت وقت نهاية أقدم من البداية) لأنها - -- كانت تُنتج متوسطاً سالباً مثل "-00h 22m". + -- كانت تُنتج متوسط مدة سالباً. (SELECT TIME_FORMAT(SEC_TO_TIME(AVG(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im') FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL AND TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish) > 0) AS driver_avg_duration, (SELECT MAX(SEC_TO_TIME(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))) FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL) AS longest_duration, (SELECT ROUND(SUM(distance),2) FROM ride) AS total_distance, diff --git a/backend/Admin/ggg.php b/backend/Admin/ggg.php index ac80fd1f..d10104bf 100644 --- a/backend/Admin/ggg.php +++ b/backend/Admin/ggg.php @@ -4,17 +4,33 @@ // أداة تشفير وفك تشفير للمشرفين // ============================================================ -require_once __DIR__ . '/../core/bootstrap.php'; +// ============================================================ +// المصادقة: هذه الأداة تفك تشفير أي حقل في قاعدة البيانات، لذا تمر عبر +// connect.php (JWT + بصمة الجهاز + Rate limiting) ثم تتطلب دور super_admin. +// +// سابقاً كان الإذن الوحيد هو رقم هاتف يُرسل داخل جسم الطلب نفسه — وهو ليس +// سرّاً: أي شخص يعرف رقماً من القائمة كان يستطيع فك تشفير بيانات المنصة +// كاملةً بلا تسجيل دخول. أُبقيت قائمة الأرقام كطبقة ثانية فوق التوكن. +// ============================================================ +require_once __DIR__ . '/../connect.php'; // نضمن أن الرد دائماً JSON header('Content-Type: application/json; charset=utf-8'); +if ($role !== 'super_admin') { + securityLog("Unauthorized encrypt/decrypt attempt", [ + 'user_id' => $user_id ?? 'unknown', + 'role' => $role ?? 'none', + ]); + jsonError('Forbidden. Super Admin access required.', 403); +} + // 1) قراءة الـ body كـ JSON أو POST $action = filterRequest('action'); $text = filterRequest('text'); $adminPhoneParam = filterRequest('admin_phone'); -// 2) التحقق من رقم هاتف الأدمن المصرّح له +// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت) $phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: ''; $ALLOWED_TOOL_PHONES = array_values( array_filter( @@ -26,11 +42,22 @@ $ALLOWED_TOOL_PHONES = array_values( $adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : ''; -if ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true)) { - securityLog("Unauthorized encrypt/decrypt attempt", ['phone' => $adminPhoneParam]); +if (!empty($ALLOWED_TOOL_PHONES) + && ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true))) { + securityLog("Encrypt/decrypt phone not in allow-list", [ + 'user_id' => $user_id ?? 'unknown', + 'phone' => $adminPhoneParam, + ]); jsonError('Access denied for this admin phone.', 403); } +// 3) سجل تدقيق: كل استخدام لهذه الأداة يُسجَّل مع هوية المنفّذ +securityLog("Encryption tool used", [ + 'user_id' => $user_id ?? 'unknown', + 'action' => $action, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', +]); + if (empty($text) || ($action !== 'encrypt' && $action !== 'decrypt')) { jsonError('Invalid input: need action=encrypt|decrypt and non-empty text.', 400); } diff --git a/backend/ride/kazan/add.php b/backend/ride/kazan/add.php index 4fc486f6..1d181768 100644 --- a/backend/ride/kazan/add.php +++ b/backend/ride/kazan/add.php @@ -1,6 +1,18 @@ 'failure', + 'message' => 'Forbidden. Super Admin access required.', + ]); + exit; +} + $kazanPercent = filterRequest("kazanPercent") ?: filterRequest("kazan"); $adminId = filterRequest("adminId"); $fuelPrice = filterRequest("fuelPrice"); diff --git a/backend/ride/kazan/update.php b/backend/ride/kazan/update.php index 4cf3aee3..273ad5e0 100644 --- a/backend/ride/kazan/update.php +++ b/backend/ride/kazan/update.php @@ -1,6 +1,18 @@ 'failure', + 'message' => 'Forbidden. Super Admin access required to change pricing.', + ]); + exit; +} + $id = filterRequest("id"); $allowedFields = [ diff --git a/backend/ride/promo/add.php b/backend/ride/promo/add.php index 7ff5c614..4251bdec 100644 --- a/backend/ride/promo/add.php +++ b/backend/ride/promo/add.php @@ -1,6 +1,18 @@ 'failure', + 'message' => 'Forbidden. Super Admin access required.', + ]); + exit; +} + $promo_code = filterRequest("promo_code"); $amount = filterRequest("amount"); $description = filterRequest("description"); diff --git a/backend/ride/promo/delete.php b/backend/ride/promo/delete.php index a0874f3a..700282f8 100644 --- a/backend/ride/promo/delete.php +++ b/backend/ride/promo/delete.php @@ -1,6 +1,18 @@ 'failure', + 'message' => 'Forbidden. Super Admin access required.', + ]); + exit; +} + $id = filterRequest("id"); $sql = "DELETE FROM `promos` WHERE `id` = :id"; diff --git a/backend/ride/promo/update.php b/backend/ride/promo/update.php index 4e187657..9818259d 100644 --- a/backend/ride/promo/update.php +++ b/backend/ride/promo/update.php @@ -1,6 +1,18 @@ 'failure', + 'message' => 'Forbidden. Super Admin access required.', + ]); + exit; +} + $id = filterRequest("id"); if (empty($id)) { jsonError("ID is required for update"); diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 187ab239..7a74f7a1 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1215,3 +1215,41 @@ h1, h2, h3, h4, h5, h6 { .coord-link { color: var(--text-muted); text-decoration: none; } .coord-link:hover { color: var(--primary); } + +/* Tariff editor */ +.notice-card { + display: flex; + align-items: flex-start; + gap: 0.7rem; + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.6; +} + +.notice-card i { font-size: 1.2rem; color: var(--info); flex-shrink: 0; } +.notice-danger { border-color: rgba(244, 63, 94, 0.35); } +.notice-danger i { color: var(--danger); } +.notice-card strong { color: var(--text-main); } + +.tariff-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.9rem; +} + +.tariff-field { display: flex; flex-direction: column; gap: 0.35rem; } + +.tariff-label { + font-size: 0.78rem; + color: var(--text-muted); + font-weight: 500; +} + +.tariff-label em { + font-style: normal; + color: var(--text-subtle); + font-size: 0.72rem; +} + +.tariff-field .form-input { padding-left: 1rem; font-size: 0.9rem; } +.tariff-field .form-input:disabled { opacity: 0.65; cursor: not-allowed; } diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index be9a9ec1..240ade71 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -848,12 +848,14 @@ ], }, { - id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff & Promos', - subtitle: 'The live Kazan tariff table and active promo codes (read-only)', - panels: [ - { title: 'Kazan tariff', path: '/ride/kazan/get.php' }, - { title: 'Promo codes', path: '/ride/promo/get.php' }, - ], + id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff Editor', + subtitle: 'The live Kazan tariff — every change here alters what passengers pay', + custom: renderTariffEditor, + }, + { + id: 'promos', group: 'Growth & Pricing', icon: 'ph-ticket', title: 'Promo Codes', + subtitle: 'Active discount codes', + panels: [{ title: 'Promo codes', path: '/ride/promo/get.php' }], }, { id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap', @@ -949,6 +951,11 @@ loadedModules.add(mod.id); const host = $(`panels_${mod.id}`); + if (mod.custom) { + await mod.custom(host); + return; + } + host.innerHTML = mod.panels.map((p) => `

${esc(p.title)}

@@ -967,6 +974,140 @@ })); } + // ── Kazan tariff editor ────────────────────────────────────────────────── + // Only these columns are accepted by ride/kazan/update.php; anything else + // sent would be silently dropped, so the form mirrors that list exactly. + const TARIFF_FIELDS = [ + { key: 'kazanPercent', label: 'Platform commission', hint: '% taken by Siro' }, + { key: 'fuelPrice', label: 'Fuel price' }, + { key: 'currency', label: 'Currency', type: 'text' }, + { key: 'normalMinPrice', label: 'Minimum fare — normal' }, + { key: 'peakMinPrice', label: 'Minimum fare — peak' }, + { key: 'lateMinPrice', label: 'Minimum fare — late night' }, + { key: 'fixedPrice', label: 'Fixed price' }, + { key: 'speedPrice', label: 'Speed' }, + { key: 'comfortPrice', label: 'Comfort' }, + { key: 'ladyPrice', label: 'Lady' }, + { key: 'electricPrice', label: 'Electric' }, + { key: 'vanPrice', label: 'Van' }, + { key: 'deliveryPrice', label: 'Delivery' }, + { key: 'mishwarVipPrice', label: 'Mishwar VIP' }, + { key: 'awfarPrice', label: 'Awfar' }, + ]; + + let tariffRows = []; + + async function renderTariffEditor(host) { + host.innerHTML = '
Loading tariff…
'; + + try { + const payload = await api('/ride/kazan/get.php'); + tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload); + } catch (err) { + if (handleApiError(err, 'tariff')) return; + host.innerHTML = `
${esc(err.message)}
`; + return; + } + + if (!tariffRows.length) { + host.innerHTML = '
No tariff rows configured.
'; + return; + } + + const readOnly = !isSuperAdmin(); + host.innerHTML = ` + ${readOnly ? ` +
+ + You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices. +
` : ` +
+ + These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account. +
`} + ${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`; + + if (readOnly) return; + + host.querySelectorAll('[data-tariff-save]').forEach((btn) => + btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host))); + host.querySelectorAll('[data-tariff-reset]').forEach((btn) => + btn.addEventListener('click', () => renderTariffEditor(host))); + } + + function tariffCard(row, index, readOnly) { + const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined); + return ` +
+
+

+ ${esc(row.country || 'Tariff')} row #${esc(row.id)} +

+ ${readOnly ? '' : ` +
+ + +
`} +
+
+ ${fields.map((f) => ` + `).join('')} +
+
`; + } + + async function saveTariff(index, host) { + if (!isSuperAdmin()) { + toast('Only a super admin can change pricing.', 'warning'); + return; + } + + const row = tariffRows[index]; + const inputs = host.querySelectorAll(`[data-tariff-input="${index}"]`); + const changes = {}; + + inputs.forEach((input) => { + const field = input.dataset.field; + const current = String(row[field] ?? ''); + const next = input.value.trim(); + if (next !== current) changes[field] = next; + }); + + if (!Object.keys(changes).length) { + toast('Nothing changed on this tariff row.', 'info'); + return; + } + + const summary = Object.entries(changes) + .map(([field, value]) => { + const label = TARIFF_FIELDS.find((f) => f.key === field)?.label || field; + return `• ${label}: ${row[field] ?? '—'} → ${value}`; + }) + .join('\n'); + + const confirmed = confirm( + `Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` + + `${summary}\n\n` + + 'This takes effect immediately for passengers.' + ); + if (!confirmed) return; + + try { + await api('/ride/kazan/update.php', { + params: { id: row.id, adminId: session?.id ?? '', ...changes }, + }); + toast('Tariff updated and recorded in the audit log.', 'success'); + renderTariffEditor(host); + } catch (err) { + if (!handleApiError(err, 'tariff-save')) toast(`Update failed: ${err.message}`, 'danger'); + } + } + function cssEscape(value) { return String(value).replace(/["\\]/g, '\\$&'); }