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:
Hamza-Ayed
2026-07-25 14:41:27 +03:00
co-authored by Claude Opus 5
parent 67f55e5192
commit 4009af8dd3
4 changed files with 284 additions and 16 deletions
@@ -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)
+41 -14
View File
@@ -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);
}
?>
+5 -2
View File
@@ -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>
+222
View File
@@ -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)}…`,