Fix dashbord.php parse error; require super_admin on pricing and crypto tools
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
852c6ece5c
commit
db4ca7dd7a
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+31
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$kazanPercent = filterRequest("kazanPercent") ?: filterRequest("kazan");
|
||||
$adminId = filterRequest("adminId");
|
||||
$fuelPrice = filterRequest("fuelPrice");
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// هذه النقطة تغيّر أجور الركاب على الإنتاج فوراً. connect.php يتحقق من صحة
|
||||
// التوكن فقط — وبدون فحص الدور كان أي توكن صالح (سائق أو راكب) قادراً على
|
||||
// تعديل تسعير المنصة بالكامل.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required to change pricing.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
|
||||
$allowedFields = [
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$promo_code = filterRequest("promo_code");
|
||||
$amount = filterRequest("amount");
|
||||
$description = filterRequest("description");
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
|
||||
$sql = "DELETE FROM `promos` WHERE `id` = :id";
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
if (empty($id)) {
|
||||
jsonError("ID is required for update");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => `
|
||||
<div class="card" data-panel="${esc(p.path)}">
|
||||
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
|
||||
@@ -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 = '<div class="card"><div class="table-msg">Loading tariff…</div></div>';
|
||||
|
||||
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 = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tariffRows.length) {
|
||||
host.innerHTML = '<div class="card"><div class="table-msg">No tariff rows configured.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const readOnly = !isSuperAdmin();
|
||||
host.innerHTML = `
|
||||
${readOnly ? `
|
||||
<div class="card notice-card">
|
||||
<i class="ph-fill ph-info"></i>
|
||||
<span>You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.</span>
|
||||
</div>` : `
|
||||
<div class="card notice-card notice-danger">
|
||||
<i class="ph-fill ph-warning"></i>
|
||||
<span><strong>These values are live.</strong> Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.</span>
|
||||
</div>`}
|
||||
${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 `
|
||||
<div class="card" data-tariff-card="${index}">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
${esc(row.country || 'Tariff')} <span class="card-sub">row #${esc(row.id)}</span>
|
||||
</h3>
|
||||
${readOnly ? '' : `
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> Reset</button>
|
||||
<button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>Review & save</span></button>
|
||||
</div>`}
|
||||
</div>
|
||||
<div class="tariff-grid">
|
||||
${fields.map((f) => `
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">${esc(f.label)}${f.hint ? ` <em>${esc(f.hint)}</em>` : ''}</span>
|
||||
<input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any"
|
||||
data-tariff-input="${index}" data-field="${esc(f.key)}"
|
||||
value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}>
|
||||
</label>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user