Files
Siro/backend/tools/panel/index.php
T

527 lines
24 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* tools/panel/index.php — لوحة إدارة مفاتيح البيئة
* ─────────────────────────────────────────────────────────────
* ‏⚠️ هذه أعلى نقطة قيمةً في المنظومة: من يصل إليها يملك كل شيء دفعة
* ‏واحدة. لذلك القيود التالية ليست تزييناً، وإزالة أيٍّ منها تُبطل
* ‏الباقي:
*
* ١. ‏الاستماع على 127.0.0.1 فقط — لا تُفتح إلا عبر نفق SSH:
* ssh -L 8099:127.0.0.1:8099 root@server
* ‏ثم http://127.0.0.1:8099 في متصفّحك.
*
* ٢. ‏عبارة مرور منفصلة تماماً عن أي حساب آخر (ENV_PANEL_PASSPHRASE
* ‏مخزَّنة كتجزئة password_hash، لا نصّاً).
*
* ٣. ‏المفاتيح المجمّدة تُعرض حالتها فقط — لا قيمتها ولا حقل تعديل.
* ‏لا يوجد مسار في هذا الملف يكتبها، لا بواجهة ولا بطلب مصنوع.
*
* ٤. ‏كل تغيير يُسجَّل: من أين، ومتى، وأي مفتاح (لا قيمته).
*
* ‏التشغيل (على المضيف، لا داخل الحاوية — يحتاج الوصول إلى docker):
* cd backend/tools/panel && php -S 127.0.0.1:8099
*/
declare(strict_types=1);
require_once __DIR__ . '/../env_registry.php';
// ═══════════════════════════════════════════════════════════════
// القيد ١ — لا شيء غير المضيف المحلي
// ═══════════════════════════════════════════════════════════════
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($remote, ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('Forbidden — local access only.');
}
// ‏ترويسات الوكيل لا تُصدَّق: لو وُضعت اللوحة خلف nginx يوماً، فإن
// ‏X-Forwarded-For تجعل REMOTE_ADDR محلياً دائماً وتُبطل القيد أعلاه.
// ‏وجودها يعني أننا خلف وكيل — وهذا ما لا نريده أصلاً.
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_FORWARDED'] as $h) {
if (!empty($_SERVER[$h])) {
http_response_code(403);
exit('Forbidden — panel must not be proxied.');
}
}
$root = dirname(__DIR__, 3);
$envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
$logFile = __DIR__ . '/panel_audit.log';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Strict',
'use_strict_mode' => true,
]);
/** ‏يسجّل حدثاً. القيم لا تُسجَّل أبداً — أسماء المفاتيح فقط. */
function auditLog(string $file, string $event, string $detail = ''): void
{
$line = sprintf("[%s] %s | %s | %s\n",
date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? '?', $event, $detail);
@file_put_contents($file, $line, FILE_APPEND);
@chmod($file, 0600);
}
// ═══════════════════════════════════════════════════════════════
// القيد ٢ — عبارة المرور
// ═══════════════════════════════════════════════════════════════
$env = envRead($envFile);
$hash = $env['ENV_PANEL_PASSPHRASE'] ?? '';
if ($hash === '' || !str_starts_with($hash, '$2y$')) {
http_response_code(503);
echo '<meta charset="utf-8"><pre style="font-family:monospace;padding:2rem">';
echo "اللوحة معطّلة: لم تُضبط ENV_PANEL_PASSPHRASE.\n\n";
echo "ولّد تجزئة وضعها في docker/.env:\n\n";
echo " php -r 'echo password_hash(\"عبارتك\", PASSWORD_BCRYPT), PHP_EOL;'\n\n";
echo "ثم: ENV_PANEL_PASSPHRASE='\$2y\$...'\n\n";
echo "اجعلها عبارة لا تشبه أي كلمة مرور أخرى عندك.\n";
echo '</pre>';
exit;
}
$error = '';
if (($_POST['action'] ?? '') === 'login') {
// ‏تأخير ثابت: يجعل تخمين العبارة بطيئاً بما يكفي، وbcrypt نفسه
// ‏يتكفّل بالباقي.
usleep(400_000);
if (password_verify((string) ($_POST['passphrase'] ?? ''), $hash)) {
session_regenerate_id(true);
$_SESSION['ok'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
auditLog($logFile, 'LOGIN_OK');
} else {
$error = 'عبارة غير صحيحة.';
auditLog($logFile, 'LOGIN_FAIL');
}
}
if (($_GET['logout'] ?? '') === '1') {
session_destroy();
header('Location: ?');
exit;
}
$authed = !empty($_SESSION['ok']);
// ═══════════════════════════════════════════════════════════════
// العمليات
// ═══════════════════════════════════════════════════════════════
$notice = '';
if ($authed && ($_POST['action'] ?? '') !== '' && ($_POST['action'] ?? '') !== 'login') {
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('CSRF mismatch');
}
$key = (string) ($_POST['key'] ?? '');
$action = (string) $_POST['action'];
// ── الحارس الجامع ──
// ‏قبل أي فرع: المجمّدة ممنوعة. الفحص هنا لا في كل فرع، فلا يمكن
// ‏لفرع جديد يُضاف لاحقاً أن ينساه.
if (in_array($key, ENV_FROZEN, true)) {
http_response_code(403);
auditLog($logFile, 'BLOCKED_FROZEN', $key);
exit('هذا المفتاح مجمّد ولا يمكن تعديله من اللوحة.');
}
$rotatable = ENV_GENERATED + ENV_SESSION_SCOPED;
if ($action === 'rotate' && isset($rotatable[$key])) {
$backup = envWrite($envFile, [$key => envGenerate($rotatable[$key])]);
if ($backup) {
auditLog($logFile, 'ROTATE', $key);
$notice = "دُوِّر $key. نسخة احتياطية: " . basename($backup)
. ' — أعد تشغيل حاويات php.';
} else {
$error = 'تعذّرت الكتابة.';
}
} elseif ($action === 'set' && in_array($key, ENV_EXTERNAL, true)) {
$value = trim((string) ($_POST['value'] ?? ''));
if ($value === '') {
$error = 'القيمة فارغة.';
} else {
$backup = envWrite($envFile, [$key => $value]);
if ($backup) {
auditLog($logFile, 'SET_EXTERNAL', $key);
$notice = "حُدِّث $key.";
} else {
$error = 'تعذّرت الكتابة.';
}
}
} elseif ($action === 'rotate_db' && isset(ENV_DB_CREDENTIALS[$key])) {
[$notice, $error] = rotateDbPassword($envFile, $logFile, $env, $key);
} else {
$error = 'عملية غير معروفة أو مفتاح غير قابل للتعديل.';
}
$env = envRead($envFile); // إعادة القراءة بعد أي تغيير
}
/**
* ‏يدوّر كلمة مرور مستخدم قاعدة بيانات بالترتيب الصحيح.
*
* ‏الترتيب هو كل شيء: ALTER USER أولاً، ثم **تحقّق باتصال فعلي**، ثم
* ‏كتابة .env. لو كتبنا .env أولاً وفشل ALTER، لصار التطبيق يحمل كلمة
* ‏لا يقبلها الخادم — انقطاع كامل. ولو نجح ALTER وفشلت الكتابة، نرجع
* ‏كلمة المرور القديمة فوراً بدل ترك الطرفين متنافرين.
*
* ‏ولا تُلمس البيانات في أي خطوة: هذا تغيير في mysql.user لا غير.
*/
function rotateDbPassword(string $envFile, string $logFile, array $env, string $key): array
{
$map = ENV_DB_CREDENTIALS[$key];
$user = $env[$map['user']] ?? '';
$dbName = $env[$map['db']] ?? '';
$rootPass = $env['MYSQL_ROOT_PASSWORD'] ?? '';
$oldPass = $env[$key] ?? '';
if ($user === '' || $dbName === '' || $rootPass === '') {
return ['', "ينقص {$map['user']} أو {$map['db']} أو MYSQL_ROOT_PASSWORD."];
}
// ‏كلمة مرور بلا رموز تحتاج هروباً في الشل — hex آمنة في كل سياق.
$newPass = envGenerate(20);
$alter = sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($newPass)
);
[$code] = runMysql($rootPass, $alter);
if ($code !== 0) {
auditLog($logFile, 'DB_ROTATE_FAIL_ALTER', $key);
return ['', 'فشل ALTER USER — لم يتغيّر شيء.'];
}
// ‏تحقّق: هل تعمل الكلمة الجديدة فعلاً؟ الثقة بأن ALTER نجح لا تكفي
// ‏(قد يكون المستخدم معرَّفاً بمضيف آخر مثلاً).
if (!verifyDbLogin($user, $newPass, $dbName)) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK', $key);
return ['', 'الكلمة الجديدة لم تعمل — أُعيدت القديمة.'];
}
$backup = envWrite($envFile, [$key => $newPass]);
if (!$backup) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK_WRITE', $key);
return ['', 'تعذّرت كتابة .env — أُعيدت كلمة المرور القديمة.'];
}
auditLog($logFile, 'DB_ROTATE', "$key ($user@$dbName)");
return ["دُوِّرت كلمة مرور $user. أعد تشغيل حاويات php الآن.", ''];
}
/** ‏هروب معرّف MySQL بالعلامة الخلفية. */
function quoteIdent(string $s): string
{
return '`' . str_replace('`', '``', $s) . '`';
}
/** ‏هروب سلسلة نصية لـ MySQL. */
function quoteLiteral(string $s): string
{
return "'" . addcslashes($s, "\\'\"\0\n\r\032") . "'";
}
/**
* ‏ينفّذ SQL في حاوية mysql.
*
* ‏كلمة المرور تمرّ عبر متغيّر بيئة لا وسيطاً في سطر الأوامر: الوسيط
* ‏يظهر في `ps` لأي مستخدم على الجهاز.
*/
function runMysql(string $rootPass, string $sql): array
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -uroot -e ' . escapeshellarg($sql) . ' 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $rootPass]);
if (!is_resource($proc)) return [1, 'proc_open failed'];
$out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $out];
}
/** ‏يتأكد أن الاعتماد الجديد يسجّل دخولاً فعلياً. */
function verifyDbLogin(string $user, string $pass, string $db): bool
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -u' . escapeshellarg($user)
. ' ' . escapeshellarg($db) . ' -e "SELECT 1" 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $pass]);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return proc_close($proc) === 0;
}
/** ‏حالة المفتاح بلا كشف قيمته. */
function keyState(array $env, string $name): string
{
if (!array_key_exists($name, $env)) return 'مفقود';
return envIsPlaceholder($env[$name]) ? 'نائب' : 'مضبوط';
}
/** ‏بصمة قصيرة تكفي للتمييز ولا تكفي للاستعمال. */
function keyFingerprint(array $env, string $name): string
{
$v = $env[$name] ?? '';
return $v === '' ? '—' : substr(hash('sha256', $v), 0, 8);
}
$csrf = $_SESSION['csrf'] ?? '';
?>
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>مفاتيح البيئة — سيرو</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background:#12151a; color:#e6e9ef; margin:0; padding:2rem 1rem; }
.wrap { max-width: 940px; margin: 0 auto; }
h1 { font-size:1.4rem; margin:0 0 .3rem; }
.sub { color:#8b93a7; font-size:.85rem; margin-bottom:1.5rem; }
.card { background:#1a1f27; border:1px solid #2a3140; border-radius:12px;
padding:1.1rem 1.25rem; margin-bottom:1.25rem; }
.card h2 { font-size:1rem; margin:0 0 .25rem; }
.card .hint { color:#8b93a7; font-size:.8rem; margin:0 0 .9rem; line-height:1.6; }
table { width:100%; border-collapse:collapse; }
td, th { padding:.5rem .4rem; text-align:right; border-bottom:1px solid #232a35;
font-size:.88rem; vertical-align:middle; }
th { color:#8b93a7; font-weight:600; font-size:.78rem; }
code { font-family: ui-monospace, monospace; font-size:.82rem; }
.state { font-size:.75rem; padding:.15rem .5rem; border-radius:999px; }
.ok { background:#12341f; color:#5ed68b; }
.miss { background:#3a1f1f; color:#ff8b8b; }
.ph { background:#3a3320; color:#ffc861; }
button { background:#2d6cdf; color:#fff; border:0; border-radius:8px;
padding:.4rem .9rem; font-size:.82rem; cursor:pointer; font-family:inherit; }
button:hover { background:#3d7cef; }
button.warn { background:#8a3a2a; }
button.warn:hover { background:#a1462f; }
input[type=text], input[type=password] { background:#12151a; border:1px solid #2a3140;
color:#e6e9ef; border-radius:8px; padding:.45rem .6rem; font-family:inherit;
font-size:.85rem; width:100%; box-sizing:border-box; }
.frozen { opacity:.72; }
.banner { border-radius:10px; padding:.75rem 1rem; margin-bottom:1.25rem; font-size:.88rem; }
.banner.err { background:#3a1f1f; border:1px solid #6b3030; }
.banner.ok { background:#12341f; border:1px solid #2f6b45; }
.banner.danger { background:#33221a; border:1px solid #7a4426; line-height:1.7; }
form.inline { display:inline; }
.row-actions { display:flex; gap:.5rem; align-items:center; }
</style>
</head>
<body>
<div class="wrap">
<?php if (!$authed): ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">وصول محلي فقط عبر نفق SSH.</p>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<form method="post">
<input type="hidden" name="action" value="login">
<p class="hint">عبارة مرور اللوحة</p>
<input type="password" name="passphrase" autofocus autocomplete="off">
<p></p>
<button type="submit">دخول</button>
</form>
</div>
<?php else: ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">
<code><?= htmlspecialchars($envFile) ?></code> ·
<a href="?logout=1" style="color:#8b93a7">خروج</a>
</p>
<?php if ($notice): ?><div class="banner ok"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="banner danger">
<strong>بعد أي تغيير</strong> — أعد تشغيل الحاويات لتقرأ الجديد:
<code>docker compose restart php php_food php_transit</code><br>
وتذكّر أن <code>WEBHOOK_SECRET_KEY</code> له نظير في تطبيق
<code>sms_relay</code> يحتاج إعادة بناء، و<code>S2S_SHARED_KEY</code>
و<code>INTERNAL_SOCKET_KEY</code> لهما نظائر في خوادم السوكيت والدفع.
</div>
<!-- ── أسرار داخلية ── -->
<div class="card">
<h2>أسرار داخلية</h2>
<p class="hint">طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_GENERATED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit"><?= $s === 'مضبوط' ? 'تدوير' : 'توليد' ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── مرتبطة بالجلسات ── -->
<div class="card">
<h2>مرتبطة بالجلسات</h2>
<p class="hint">
تدويرها يُخرج <strong>كل</strong> السائقين والركّاب من حساباتهم ويطالبهم
بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_SESSION_SCOPED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline"
onsubmit="return confirm('سيُخرج هذا كل المستخدمين من حساباتهم. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── قواعد البيانات ── -->
<div class="card">
<h2>كلمات مرور قواعد البيانات</h2>
<p class="hint">
التدوير يجري بالترتيب الصحيح: <code>ALTER USER</code> ثم تحقّق باتصال
فعلي ثم كتابة <code>.env</code> — وإن فشلت أي خطوة تُستعاد الكلمة
القديمة فوراً. <strong>لا مساس بالبيانات ولا إعادة بناء</strong>؛ هذا
تغيير في <code>mysql.user</code> لا غير.
</p>
<table>
<tr><th>المفتاح</th><th>المستخدم</th><th>القاعدة</th><th>الحالة</th><th></th></tr>
<?php foreach (ENV_DB_CREDENTIALS as $name => $map):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><code><?= htmlspecialchars($env[$map['user']] ?? '—') ?></code></td>
<td><code><?= htmlspecialchars($env[$map['db']] ?? '—') ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<?php if ($s === 'مضبوط'): ?>
<form method="post" class="inline"
onsubmit="return confirm('تدوير كلمة مرور القاعدة. أعد تشغيل حاويات php فوراً بعدها. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate_db">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
<?php else: ?><span style="color:#8b93a7">—</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── طرف ثالث ── -->
<div class="card">
<h2>مفاتيح طرف ثالث</h2>
<p class="hint">لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th style="width:45%">قيمة جديدة</th><th></th></tr>
<?php foreach (ENV_EXTERNAL as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<form method="post">
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<input type="text" name="value" autocomplete="off" placeholder="اتركه فارغاً لعدم التغيير">
</td>
<td><button type="submit">حفظ</button></td>
</form>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── المجمّدة ── -->
<div class="card frozen">
<h2>⛔ مفاتيح مجمّدة — للعلم فقط</h2>
<p class="hint">
بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب
وهواتفهم ولوحات السيارات مشفّرة بـ <code>ENC_KEY</code>، وأعمدة
<code>*_bidx</code> محسوبة بـ <code>BLIND_INDEX_PEPPER</code>.
تغيير أيٍّ منها <strong>لا يعيد تشفير شيء</strong> — يجعل كل صف قديم
غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها
مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد،
ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th></tr>
<?php foreach (ENV_FROZEN as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<p class="sub">سجلّ التغييرات: <code>backend/tools/panel/panel_audit.log</code></p>
<?php endif; ?>
</div>
</body>
</html>