Commit b117f332 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(branding): the game's look is locked; the manager only shows it

Owner decision 2026-10-26: the manager must not change the game's visual
identity. The app colours tab is read-only (current value or the game's
default from its tokens.css), the image slots are read-only, and the write
paths are removed: branding/game-color, branding/game-color/reset and
branding/upload-asset (routes and methods). updateColor and the palette reset
now touch only the manager's own palette keys that the game never reads, so
no request can reach a platform_theme key the game reads; nothing in the
manager writes platform_assets or theme_settings.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 3a5ae9a4
......@@ -208,9 +208,6 @@ return [
'branding/assets' => ['module' => 'branding', 'action' => 'assets'],
'branding/update-color' => ['module' => 'branding', 'action' => 'updateColor'],
'branding/reset-colors' => ['module' => 'branding', 'action' => 'resetColors'],
'branding/upload-asset' => ['module' => 'branding', 'action' => 'uploadAsset'],
'branding/game-color' => ['module' => 'branding', 'action' => 'updateGameColor'],
'branding/game-color/reset' => ['module' => 'branding', 'action' => 'resetGameColor'],
// Analytics
'analytics' => ['module' => 'analytics', 'action' => 'index'],
......
......@@ -2,6 +2,13 @@
require_once MODULES_PATH . '/tournaments/services/PlayerEngineService.php';
/**
* Branding. The game's visual identity is locked (owner decision 2026-10-26):
* its colours (GAME_COLORS) and every image slot it renders are shown here
* read-only, and this module writes nothing the game reads: no game keys in
* platform_theme, no platform_assets, no theme_settings. The only write left
* is the manager's own palette (ThemeService keys the game does not read).
*/
class BrandingController
{
private Database $db;
......@@ -99,16 +106,20 @@ class BrandingController
unset($a);
usort($assets, fn($x, $y) => [self::STATE_ORDER[$x['game_state']], $x['id']] <=> [self::STATE_ORDER[$y['game_state']], $y['id']]);
$gameAdminUrl = PlayerEngineService::baseUrl() . '/admin/branding.php';
$pageTitle = 'الهوية والعلامة التجارية';
$moduleCSS = 'branding';
$moduleJS = 'branding';
View::render('branding/index', compact('colors', 'gameColors', 'assets', 'gameAdminUrl', 'pageTitle', 'moduleCSS', 'moduleJS'));
View::render('branding/index', compact('colors', 'gameColors', 'assets', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
private const STATE_ORDER = ['live' => 0, 'conditional' => 1, 'ignored' => 2];
/** A manager palette key the game never reads: the only platform_theme rows this module may write. */
private static function isManagerOnlyKey(string $id): bool
{
return ThemeService::isManagerKey($id) && !isset(self::GAME_COLORS[$id]);
}
/**
* Does the game read this asset slot? From El3ab public/js/core/theme.js,
* icons.js, reactions.js, juice.js, audio.js, ludo/ui/die.js and
......@@ -134,15 +145,9 @@ class BrandingController
return ['ignored', 'لا يقرؤها التطبيق'];
}
/** Slots a new upload may create: only ones the game reads. */
private static function isGameSlot(string $id): bool
{
return (bool)preg_match('/^(icon_[a-z0-9_-]{2,40}|chess_piece_[wb][KQRBNP]|chess_bot_[a-z0-9_-]{2,40}|emoji_dice_[1-6]|logo|logo_splash|logo_icon|tab_(play|rank|social|tournaments|profile|shop))$/', $id);
}
/**
* The game's default colours, read from its tokens.css (cached ten
* minutes), so a reset can show what the app falls back to.
* minutes), to show what the app uses where nothing is stored.
*/
private function gameTokenDefaults(): array
{
......@@ -164,157 +169,39 @@ class BrandingController
return $out;
}
/** Set one of the game's colours (same rows the game's own branding admin writes). */
public function updateGameColor(array $params, string $method): void
public function updateColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = (string)($_POST['id'] ?? '');
$value = strtoupper(trim((string)($_POST['value'] ?? '')));
if (!isset(self::GAME_COLORS[$id])) {
Response::error('هذا اللون لا يقرؤه التطبيق', '/branding');
$value = (string)($_POST['value'] ?? '');
// Only the manager's own palette. A key the game reads (GAME_COLORS,
// or anything outside ThemeService's map) is the game's look, locked.
if (!self::isManagerOnlyKey($id)) {
Response::error('هذا المفتاح يقرؤه التطبيق؛ هوية التطبيق مقفلة', '/branding');
return;
}
if (!preg_match('/^#[0-9A-F]{6}$/', $value)) {
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
Response::error('قيمة لون غير صحيحة', '/branding');
return;
}
$existing = $this->db->selectOne('platform_theme', ['id' => "eq.{$id}", 'select' => 'id,value']);
if ($existing) {
$ok = $this->db->update('platform_theme', ['id' => "eq.{$id}"], ['value' => $value, 'updated_at' => date('c')]);
} else {
$defaults = $this->gameTokenDefaults();
$ok = $this->db->insert('platform_theme', [
'id' => $id,
'category' => 'game',
'group_name' => 'player_app',
'label' => $id,
'label_ar' => self::GAME_COLORS[$id][0],
$ok = $this->db->update('platform_theme', ['id' => "eq.{$id}", 'category' => 'eq.color'], [
'value' => $value,
'default_value' => $defaults[$id] ?? $value,
'value_type' => 'hex',
'updated_at' => date('c'),
]);
}
if ($ok === null) {
Response::error('لم يُحفظ اللون: ' . $this->db->lastErrorMessage(), '/branding');
return;
}
AuditLog::log('update', 'platform_theme', $id, $existing ? ['value' => $existing['value']] : null, ['value' => $value]);
Response::success('تم حفظ اللون. يراه اللاعبون عند فتح التطبيق', '/branding');
}
/** Remove an override: the game falls back to its tokens.css colour. */
public function resetGameColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = (string)($_POST['id'] ?? '');
if (!isset(self::GAME_COLORS[$id])) {
Response::error('هذا اللون لا يقرؤه التطبيق', '/branding');
return;
}
$this->db->delete('platform_theme', ['id' => "eq.{$id}"]);
AuditLog::log('reset', 'platform_theme', $id);
Response::success('أُعيد اللون إلى تصميم التطبيق الافتراضي', '/branding');
}
public function updateColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = $_POST['id'] ?? '';
$value = $_POST['value'] ?? '';
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
Response::error('قيمة لون غير صحيحة', '/branding');
return;
}
$this->db->update('platform_theme', ['id' => "eq.{$id}"], [
'value' => $value,
'updated_at' => date('c'),
]);
ThemeService::invalidateCache();
AuditLog::log('update', 'platform_theme', $id, null, ['value' => $value]);
Response::success('تم تحديث اللون', '/branding');
}
public function uploadAsset(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = trim((string)($_POST['id'] ?? ''));
$existing = $id !== '' ? $this->db->selectOne('platform_assets', ['id' => "eq.{$id}", 'select' => 'id']) : null;
if (!$existing && !self::isGameSlot($id)) {
Response::error('اسم الخانة غير معروف للتطبيق (مثال: icon_coin، chess_piece_wK، emoji_dice_6)', '/branding');
return;
}
if (empty($_FILES['asset']) || $_FILES['asset']['error'] !== UPLOAD_ERR_OK) {
Response::error('خطأ في رفع الملف', '/branding');
return;
}
$file = $_FILES['asset'];
$allowed = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'];
if (!in_array($file['type'], $allowed)) {
Response::error('نوع الملف غير مسموح (PNG/JPG/SVG/WebP)', '/branding');
return;
}
if ($file['size'] > 5 * 1024 * 1024) {
Response::error('حجم الملف يتجاوز 5MB', '/branding');
return;
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = $id . '.' . $ext;
$storagePath = 'branding/' . $filename;
// Through SupabaseStorage, which sends the apikey header Kong requires;
// the hand-rolled request here sent only Authorization and every upload
// was refused with "No API key found in request".
try {
$url = SupabaseStorage::getInstance()->upload('profile-images', $storagePath, $file['tmp_name'], $file['type']);
} catch (RuntimeException $e) {
Response::error('فشل رفع الملف إلى التخزين: ' . $e->getMessage(), '/branding');
return;
}
$assetUrl = $url . '&v=' . time();
if ($existing) {
$ok = $this->db->update('platform_assets', ['id' => "eq.{$id}"], [
'asset_url' => $assetUrl,
'mime_type' => $file['type'],
'file_size_kb' => (int)ceil($file['size'] / 1024),
'updated_at' => date('c'),
]);
} else {
$ok = $this->db->insert('platform_assets', [
'id' => $id,
'category' => 'custom',
'label' => $id,
'asset_url' => $assetUrl,
'mime_type' => $file['type'],
'file_size_kb' => (int)ceil($file['size'] / 1024),
]);
}
if ($ok === null) {
Response::error('رُفع الملف لكن لم يُربط بالخانة: ' . $this->db->lastErrorMessage(), '/branding');
return;
}
AuditLog::log('upload', 'platform_asset', $id);
[$state] = self::slotStatus($id);
Response::success($state === 'ignored' ? 'تم رفع الملف، لكن التطبيق لا يقرأ هذه الخانة' : 'تم رفع الملف. يظهر للاعبين عند فتح التطبيق', '/branding');
}
public function resetColors(array $params, string $method): void
{
Auth::requireCsrf();
......@@ -326,6 +213,7 @@ class BrandingController
]);
foreach ($colors as $color) {
if (!self::isManagerOnlyKey($color['id'])) continue; // the game's keys are locked
if (!empty($color['default_value'])) {
$this->db->update('platform_theme', ['id' => "eq.{$color['id']}"], [
'value' => $color['default_value'],
......
......@@ -13,8 +13,8 @@
<!-- Game Colors Tab -->
<div id="gameColorsTab" class="tab-content">
<div class="card mb-4 p-4">
<p class="text-sm">هذه هي الألوان التي يسمح التطبيق بتغييرها (تصميم Night Arcade). اللون غير المحدد يأخذ قيمته من تصميم التطبيق. يرى اللاعبون التغيير عند فتح التطبيق.</p>
<p class="text-sm text-muted mt-2">يمكن أيضًا تعديلها من لوحة الهوية في التطبيق نفسه: <a href="<?= View::e($gameAdminUrl) ?>" target="_blank" rel="noopener" class="text-link" dir="ltr"><?= View::e($gameAdminUrl) ?></a> — الطرفان يكتبان القيم نفسها.</p>
<p class="text-sm">هوية التطبيق البصرية مقفلة (قرار المالك، 26 أكتوبر 2026): هذه ألوان التطبيق الحالية للاطلاع فقط، ولا تُعدّل من لوحة الإدارة.</p>
<p class="text-sm text-muted mt-2">اللون "الافتراضي" يأتي من تصميم التطبيق نفسه؛ "مخصص" يعني قيمة محفوظة يقرؤها التطبيق.</p>
</div>
<?php foreach ($gameColors as $group => $items): ?>
<div class="card mb-4">
......@@ -34,19 +34,7 @@
</div>
</div>
<div class="flex items-center gap-2 mt-3">
<form method="POST" action="/branding/game-color" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($c['id']) ?>">
<input type="color" name="value" value="<?= View::e($shown) ?>" aria-label="<?= View::e($c['label']) ?>" style="width: 40px; height: 32px; border: none; cursor: pointer;">
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
</form>
<?php if ($c['value']): ?>
<form method="POST" action="/branding/game-color/reset">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($c['id']) ?>">
<button type="submit" class="btn btn-ghost btn-sm">الافتراضي</button>
</form>
<?php endif; ?>
<input type="text" class="form-input" style="width: 100px; padding: var(--space-1) var(--space-2); font-size: 12px;" value="<?= View::e($shown) ?>" dir="ltr" readonly aria-label="<?= View::e($c['label']) ?>">
</div>
</div>
<?php endforeach; ?>
......@@ -323,7 +311,7 @@
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد ألوان محفوظة</h3><p class="empty-state-text">أضف ألوان العلامة التجارية من جدول platform_theme</p></div></div>
<?php else: ?>
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-secondary">ألوان لوحة الإدارة هذه فقط. التطبيق لا يقرأ هذه المفاتيح؛ ألوانه في تبويب "ألوان التطبيق".</p>
<p class="text-sm text-secondary">ألوان لوحة الإدارة هذه فقط. التطبيق لا يقرأ هذه المفاتيح؛ أي مفتاح يقرؤه التطبيق مقفل هنا.</p>
<form method="POST" action="/branding/reset-colors" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-ghost btn-sm" onclick="return confirm('هل أنت متأكد؟ سيتم إعادة جميع الألوان إلى الافتراضي.')">
......@@ -342,6 +330,12 @@
<div class="text-xs text-muted" dir="ltr"><?= View::e($color['id']) ?></div>
</div>
</div>
<?php if (isset(BrandingController::GAME_COLORS[$color['id']])): ?>
<div class="flex items-center gap-2 mt-3">
<input type="text" class="form-input" style="width: 100px; padding: var(--space-1) var(--space-2); font-size: 12px;" value="<?= View::e($color['value']) ?>" dir="ltr" readonly>
<span class="text-xs text-muted">يقرؤه التطبيق أيضًا؛ مقفل</span>
</div>
<?php else: ?>
<form method="POST" action="/branding/update-color" class="flex items-center gap-2 mt-3">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($color['id']) ?>">
......@@ -349,6 +343,7 @@
<input type="text" class="form-input" style="width: 100px; padding: var(--space-1) var(--space-2); font-size: 12px;" value="<?= View::e($color['value']) ?>" dir="ltr" readonly>
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
......@@ -359,17 +354,8 @@
<div id="assetsTab" class="tab-content hidden">
<?php $stateLabels = ['live' => ['يقرؤه التطبيق', 'badge-success'], 'conditional' => ['بشرط', 'badge-warning'], 'ignored' => ['لا يقرؤه التطبيق', 'badge-default']]; ?>
<div class="card mb-4 p-4">
<p class="text-sm">الخانات التي يقرؤها التطبيق: قطع الشطرنج (chess_piece_wK ...)، صور البوتات (chess_bot_المعرف)، أي أيقونة (icon_الاسم، مثل icon_coin و icon_crown)، صور التفاعلات (icon_emote_...)، وجوه نرد اللودو (emoji_dice_1 إلى 6)، والأصوات (sfx_...). الشعار وأيقونات الشريط السفلي (tab_...) تُقرأ فقط إن رُفعت بعد 19 أكتوبر 2026. باقي الخانات القديمة، ومنها خانات الرموز التعبيرية، لا يقرؤها التطبيق.</p>
<form method="POST" action="/branding/upload-asset" enctype="multipart/form-data" class="flex gap-2 items-end flex-wrap mt-3">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">رفع لخانة جديدة</label>
<input type="text" name="id" class="form-input" required dir="ltr" placeholder="icon_coin" pattern="[a-zA-Z0-9_-]+">
</div>
<input type="file" name="asset" accept="image/png,image/jpeg,image/svg+xml,image/webp" class="form-input" style="font-size: 12px; max-width: 260px;" required>
<button type="submit" class="btn btn-primary btn-sm">رفع</button>
</form>
<p class="text-xs text-muted mt-2">الأصوات تُرفع من لوحة الهوية في التطبيق لأنها ليست صورًا.</p>
<p class="text-sm">صور التطبيق وأيقوناته مقفلة (قرار المالك، 26 أكتوبر 2026): تُعرض هنا للاطلاع فقط، ولا يُرفع ولا يُستبدل شيء منها من لوحة الإدارة.</p>
<p class="text-xs text-muted mt-2">الخانات التي يقرؤها التطبيق: قطع الشطرنج (chess_piece_wK ...)، صور البوتات (chess_bot_المعرف)، الأيقونات (icon_الاسم)، صور التفاعلات (icon_emote_...)، وجوه نرد اللودو (emoji_dice_1 إلى 6)، والأصوات (sfx_...). الشعار وأيقونات الشريط السفلي (tab_...) تُقرأ فقط إن رُفعت بعد 19 أكتوبر 2026. باقي الخانات القديمة لا يقرؤها التطبيق.</p>
</div>
<?php if (empty($assets)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد أصول مرئية</h3></div></div>
......@@ -390,13 +376,7 @@
<span class="badge <?= $st[1] ?> badge-sm"><?= $st[0] ?></span>
</div>
<p class="text-xs text-muted" dir="ltr"><?= View::e($asset['id']) ?></p>
<p class="text-xs text-secondary mb-3"><?= View::e($asset['game_note']) ?><?php if (!empty($asset['updated_at'])): ?> — آخر رفع <?= Time::show($asset['updated_at'], 'Y/m/d') ?><?php endif; ?></p>
<form method="POST" action="/branding/upload-asset" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($asset['id']) ?>">
<input type="file" name="asset" accept="image/*" class="form-input" style="font-size: 12px;">
<button type="submit" class="btn btn-primary btn-sm mt-2 w-full">رفع</button>
</form>
<p class="text-xs text-secondary"><?= View::e($asset['game_note']) ?><?php if (!empty($asset['updated_at'])): ?> — آخر رفع <?= Time::show($asset['updated_at'], 'Y/m/d') ?><?php endif; ?></p>
</div>
<?php endforeach; ?>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment