Add campaign launcher and app version manager; fix SQL injection in updatePackages
serviceapp/updatePackages.php built its UPDATE by interpolating the request values straight into the SQL string, so any caller with a valid token could execute arbitrary SQL through the version field. It now uses bound parameters, requires an admin role, validates the version format, and writes an audit entry. trigger_campaign.php gains dry_run=1: it performs the same Gemini analysis and target selection but returns before creating the promo code and before dispatching any notification. Launching without previewing was the only option before, and a launch writes a seven-day discount and pushes to every passenger in the country. Console: - Campaign launcher with a mandatory preview. Launching stays disabled until the current parameters have been previewed, and re-locks if any parameter changes afterwards or once a launch completes. - App version manager with the same version-format check as the server and a confirmation naming the old and new values. Cache busting: assets are served straight off a bind mount with no version, so browsers kept running the previously cached build after a deploy. Both asset links now carry ?v=, and the build id is shown in Session & Security and printed in the diagnostics report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67f55e5192
commit
4009af8dd3
@@ -109,6 +109,22 @@ try {
|
||||
$dispatchedPassengers = [];
|
||||
$fcmErrors = [];
|
||||
|
||||
// 5.5 وضع المعاينة: يُرجع ما ستفعله الحملة (النص، الكود، حجم الجمهور)
|
||||
// دون إنشاء كود ترويجي ودون إرسال أي إشعار. الحملة تُنشئ خصماً حقيقياً
|
||||
// وتصل كل ركاب الدولة، فوجود معاينة قبل الإطلاق ضروري.
|
||||
if (filterRequest('dry_run') === '1') {
|
||||
jsonSuccess([
|
||||
'dry_run' => true,
|
||||
'campaign_created' => false,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percent' => $discountVal,
|
||||
'region' => $regionName,
|
||||
'country_code' => strtoupper($countryCode),
|
||||
'audience_size' => count($targets),
|
||||
'ai_analysis' => $aiCampaign,
|
||||
], 'Preview only — no promo code was created and no notification was sent.');
|
||||
}
|
||||
|
||||
// 6. Save broadcast promo for this campaign (Option 1 - promos table adjustment)
|
||||
$sqlPromo = "INSERT INTO promos
|
||||
(promo_code, amount, description, passengerID, source, validity_start_date, validity_end_date)
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// Get 'id' and 'version' from the request
|
||||
$id = filterRequest("id");
|
||||
// رقم الإصدار يقرره العميل: كل تطبيقات سيرو تقارن نفسها به وتفرض التحديث،
|
||||
// فتغييره يؤثر على كل المستخدمين. connect.php يتحقق من صحة التوكن فقط.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
$version = filterRequest("version");
|
||||
|
||||
if (empty($id) || empty($version)) {
|
||||
jsonError("Both id and version are required.", 400);
|
||||
}
|
||||
|
||||
// شكل الإصدار: أرقام ونقاط فقط (مثل 1.2.3) — يمنع أي محتوى آخر.
|
||||
if (!preg_match('/^\d+(\.\d+){0,3}$/', $version)) {
|
||||
jsonError("Invalid version format. Use digits separated by dots, e.g. 1.4.2", 400);
|
||||
}
|
||||
|
||||
$sql = "UPDATE `packageInfo` SET `version` ='$version' WHERE `id` = '$id'";
|
||||
/**
|
||||
* سابقاً كان الاستعلام يُبنى بدمج القيم مباشرة:
|
||||
* "UPDATE packageInfo SET version ='$version' WHERE id = '$id'"
|
||||
* وهو حقن SQL مباشر — أي مستخدم يملك توكناً صالحاً كان يستطيع تنفيذ ما يشاء
|
||||
* على قاعدة البيانات عبر حقل الإصدار. الآن القيم مرتبطة كمعاملات.
|
||||
*/
|
||||
$stmt = $con->prepare("UPDATE `packageInfo` SET `version` = :version WHERE `id` = :id");
|
||||
$stmt->execute([
|
||||
':version' => $version,
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
// Prepare and execute the statement
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
error_log("Updating package: ID = $sql, Version = $version");
|
||||
if (function_exists('logAudit')) {
|
||||
try {
|
||||
logAudit($con, (string) ($user_id ?? 'unknown'), 'تحديث إصدار التطبيق', 'packageInfo', $id, [
|
||||
'version' => $version,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[updatePackages] audit failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Debugging: Check if the query affected any rows
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// If rows were affected, print success
|
||||
echo json_encode(['status' => 'success', 'message' => "Package version updated successfully for ID $id"]);
|
||||
jsonSuccess(['id' => $id, 'version' => $version], "Package version updated successfully for ID $id");
|
||||
} else {
|
||||
// If no rows were affected, print failure and debug the query
|
||||
echo json_encode(['status' => 'failure', 'message' => "Failed to update package version. No rows affected. ID: $id, Version: $version"]);
|
||||
jsonError("No package row was updated — check that ID $id exists and the version differs.", 404);
|
||||
}
|
||||
?>
|
||||
@@ -12,7 +12,10 @@
|
||||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<!-- ?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-2">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -601,6 +604,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/app.js?v=2026-07-25-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// 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-2';
|
||||
|
||||
const SESSION_KEY = 'siro_admin_user';
|
||||
const FP_KEY = 'siro_web_fp';
|
||||
const API_BASE_KEY = 'siro_api_base';
|
||||
@@ -785,6 +790,7 @@
|
||||
['Token expires', expiresAt.toLocaleString()],
|
||||
['Device fingerprint', deviceFingerprint.slice(0, 24) + '…'],
|
||||
['API endpoint', location.origin + API_BASE],
|
||||
['Console build', BUILD],
|
||||
];
|
||||
el.sessionInfo.innerHTML = rows.map(([k, v]) =>
|
||||
`<div class="kv-row"><span>${k}</span><strong>${esc(String(v))}</strong></div>`).join('');
|
||||
@@ -913,6 +919,18 @@
|
||||
subtitle: 'Draft routes submitted by organisations, awaiting a decision',
|
||||
custom: renderRouteApprovals,
|
||||
},
|
||||
{
|
||||
id: 'campaigns', superOnly: true, group: 'Growth & Pricing', icon: 'ph-rocket-launch',
|
||||
title: 'Campaign Launcher',
|
||||
subtitle: 'Generate an AI pricing campaign, preview it, then dispatch',
|
||||
custom: renderCampaigns,
|
||||
},
|
||||
{
|
||||
id: 'appVersion', superOnly: true, group: 'Administration', icon: 'ph-device-mobile',
|
||||
title: 'App Versions',
|
||||
subtitle: 'The version each Siro app checks itself against',
|
||||
custom: renderAppVersions,
|
||||
},
|
||||
{
|
||||
id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple',
|
||||
title: 'Broadcast Notification',
|
||||
@@ -1058,6 +1076,209 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Campaign launcher ────────────────────────────────────────────────────
|
||||
// trigger_campaign.php asks Gemini for a campaign, writes a promo code valid
|
||||
// for seven days, and pushes it to every passenger in the country. The
|
||||
// preview (dry_run=1) runs the same analysis and stops before both.
|
||||
function renderCampaigns(host) {
|
||||
host.innerHTML = `
|
||||
<div class="card notice-card notice-danger">
|
||||
<i class="ph-fill ph-warning"></i>
|
||||
<span><strong>Launching creates a real discount code and notifies every passenger in the selected country.</strong>
|
||||
The promo stays valid for seven days. Always preview first.</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Campaign parameters</h3></div>
|
||||
<div class="tariff-grid">
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Country</span>
|
||||
<select class="select-input" id="cmpCountry">
|
||||
<option value="JO">Jordan</option>
|
||||
<option value="SY">Syria</option>
|
||||
<option value="EG">Egypt</option>
|
||||
<option value="IQ">Iraq</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Region <em>defaults to the capital</em></span>
|
||||
<input type="text" class="form-input" id="cmpRegion" placeholder="Amman">
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Siro base price</span>
|
||||
<input type="number" step="any" class="form-input" id="cmpBasePrice" value="1.25">
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-base-row" style="margin-top:1rem;">
|
||||
<button class="btn btn-secondary btn-sm" id="cmpPreview"><i class="ph ph-eye"></i> <span>Preview</span></button>
|
||||
<button class="btn btn-primary btn-sm" id="cmpLaunch" disabled><i class="ph ph-rocket-launch"></i> <span>Launch campaign</span></button>
|
||||
<span class="stamp" id="cmpStatus">Preview first to enable launching.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="cmpResult">
|
||||
<div class="table-msg">No analysis run yet.</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="cmpLog"><div class="table-msg">Loading campaign history…</div></div>`;
|
||||
|
||||
$('cmpPreview').addEventListener('click', () => runCampaign(true));
|
||||
$('cmpLaunch').addEventListener('click', () => runCampaign(false));
|
||||
loadCampaignLog();
|
||||
}
|
||||
|
||||
// A launch is only allowed for parameters that were previewed, so an edit
|
||||
// after previewing disarms the button again.
|
||||
let previewedCampaign = null;
|
||||
|
||||
function campaignParams() {
|
||||
return {
|
||||
country_code: $('cmpCountry').value,
|
||||
region_name: $('cmpRegion').value.trim(),
|
||||
siro_base_price: $('cmpBasePrice').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCampaign(isPreview) {
|
||||
const params = campaignParams();
|
||||
const signature = JSON.stringify(params);
|
||||
|
||||
if (!isPreview) {
|
||||
if (signature !== previewedCampaign) {
|
||||
toast('Parameters changed since the preview — preview again before launching.', 'warning');
|
||||
$('cmpLaunch').disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!confirm(
|
||||
`Launch this campaign in ${params.country_code}?\n\n` +
|
||||
'It creates a discount code valid for 7 days and pushes a notification to every passenger there.\n\n' +
|
||||
'This cannot be undone.'
|
||||
)) return;
|
||||
}
|
||||
|
||||
const btn = isPreview ? $('cmpPreview') : $('cmpLaunch');
|
||||
busy(btn, true, isPreview ? 'Analysing…' : 'Launching…');
|
||||
$('cmpResult').innerHTML = '<div class="table-msg">Running market analysis…</div>';
|
||||
|
||||
try {
|
||||
const payload = await api('/Admin/marketing/trigger_campaign.php', {
|
||||
params: isPreview ? { ...params, dry_run: '1' } : params,
|
||||
});
|
||||
|
||||
$('cmpResult').innerHTML = `<div class="card-header"><h3 class="card-title">${isPreview ? 'Preview' : 'Launch result'}</h3></div><div class="panel-body"></div>`;
|
||||
renderPayload($('cmpResult').querySelector('.panel-body'), payload);
|
||||
|
||||
if (isPreview) {
|
||||
const opportunity = payload?.ai_analysis?.opportunity_detected ?? payload?.campaign_created;
|
||||
previewedCampaign = signature;
|
||||
$('cmpLaunch').disabled = false;
|
||||
$('cmpStatus').textContent = opportunity === false
|
||||
? 'The AI found no opportunity — launching would still send.'
|
||||
: `Previewed ${params.country_code}. Launch is now enabled.`;
|
||||
} else {
|
||||
previewedCampaign = null;
|
||||
$('cmpLaunch').disabled = true;
|
||||
$('cmpStatus').textContent = `Launched at ${new Date().toLocaleTimeString()}`;
|
||||
toast('Campaign dispatched.', 'success');
|
||||
loadCampaignLog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!handleApiError(err, 'campaign')) {
|
||||
$('cmpResult').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||
toast(err.message, 'danger');
|
||||
}
|
||||
} finally {
|
||||
busy(btn, false, isPreview ? 'Preview' : 'Launch campaign');
|
||||
// busy() clears `disabled`, so re-apply the arming rule afterwards:
|
||||
// launching stays locked until the current parameters are previewed.
|
||||
$('cmpLaunch').disabled = previewedCampaign !== JSON.stringify(campaignParams());
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCampaignLog() {
|
||||
const panel = $('cmpLog');
|
||||
try {
|
||||
const payload = await api('/Admin/marketing/get_campaigns_log.php');
|
||||
panel.innerHTML = '<div class="card-header"><h3 class="card-title">Campaign history</h3></div><div class="panel-body"></div>';
|
||||
renderPayload(panel.querySelector('.panel-body'), payload);
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'campaign-log')) return;
|
||||
panel.innerHTML = `<div class="card-header"><h3 class="card-title">Campaign history</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── App versions ─────────────────────────────────────────────────────────
|
||||
async function renderAppVersions(host) {
|
||||
host.innerHTML = '<div class="card"><div class="table-msg">Loading package versions…</div></div>';
|
||||
|
||||
let packages = [];
|
||||
try {
|
||||
packages = normaliseRows(await api('/serviceapp/getPackages.php'));
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'packages')) return;
|
||||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!packages.length) {
|
||||
host.innerHTML = '<div class="card"><div class="table-msg">No package rows configured.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="card notice-card">
|
||||
<i class="ph-fill ph-info"></i>
|
||||
<span>Each app compares its own build against this number on launch. Raising it can force every
|
||||
user of that app to update before they can continue.</span>
|
||||
</div>
|
||||
${packages.map((pkg, index) => `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
${esc(pkg.name || pkg.packageName || pkg.app_name || `Package #${pkg.id}`)}
|
||||
<span class="card-sub">row #${esc(pkg.id)}</span>
|
||||
</h3>
|
||||
<button class="btn btn-primary btn-sm" data-pkg-save="${index}"><i class="ph ph-floppy-disk"></i> <span>Update version</span></button>
|
||||
</div>
|
||||
<div class="tariff-grid">
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Current version</span>
|
||||
<input type="text" class="form-input" data-pkg-version="${index}" value="${esc(pkg.version ?? '')}">
|
||||
</label>
|
||||
</div>
|
||||
</div>`).join('')}`;
|
||||
|
||||
host.querySelectorAll('[data-pkg-save]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => saveVersion(packages[Number(btn.dataset.pkgSave)], Number(btn.dataset.pkgSave), host)));
|
||||
}
|
||||
|
||||
async function saveVersion(pkg, index, host) {
|
||||
const input = host.querySelector(`[data-pkg-version="${index}"]`);
|
||||
const version = input.value.trim();
|
||||
|
||||
if (version === String(pkg.version ?? '')) {
|
||||
toast('Version unchanged.', 'info');
|
||||
return;
|
||||
}
|
||||
// Mirrors the server-side check so a typo is caught before the request.
|
||||
if (!/^\d+(\.\d+){0,3}$/.test(version)) {
|
||||
toast('Use digits separated by dots, e.g. 1.4.2', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!confirm(
|
||||
`Set ${pkg.name || `package #${pkg.id}`} to version ${version} (was ${pkg.version ?? '—'})?\n\n` +
|
||||
'Users on an older build may be prompted or forced to update.'
|
||||
)) return;
|
||||
|
||||
try {
|
||||
await api('/serviceapp/updatePackages.php', { params: { id: pkg.id, version } });
|
||||
toast(`Version set to ${version}.`, 'success');
|
||||
renderAppVersions(host);
|
||||
} catch (err) {
|
||||
if (!handleApiError(err, 'package-save')) toast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Driver document review ───────────────────────────────────────────────
|
||||
// The list is paged server-side (limit/offset). Activation posts
|
||||
// status=active to Admin/driver/updateDriverFromAdmin.php, exactly as the
|
||||
@@ -1846,6 +2067,7 @@
|
||||
|
||||
const lines = [
|
||||
`Siro Admin diagnostics — ${new Date().toISOString()}`,
|
||||
`Console build: ${BUILD}`,
|
||||
`Page origin : ${location.origin}`,
|
||||
`API base : ${API_BASE}`,
|
||||
`Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,
|
||||
|
||||
Reference in New Issue
Block a user