Commit 90ab18f7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: invite codes and links, player-side orgs, guest upgrade, and the game honouring the manager

Invite codes and links (one system for org, tournament, group and promo codes)
- Redeem screen, invitation card, reveal and history; a code box at signup and
  login; links (/r/CODE, ?org=, ?code=, ?join=) survive reloads, a closed tab
  and the Google redirect; WhatsApp/Telegram previews with a shaped Arabic share
  card. Canonical codes match the SQL exactly (tests share the vectors).
- A guest who registers is upgraded in place: same account, coins, history and
  held invites.
- Organisations, player side only (staff manage them in the manager): browse,
  read-only org page with announcements, join, apply with a private proof,
  leave, primary org on the profile. Proofs moved to a private bucket.
- The groups join box resolves any code through the same rate-limited path.

The game honours the manager
- Feature flags and maintenance mode, which games are on and their order,
  daily reward and Elo K from reward_config, a ban screen with the reason,
  ads on the table with counted impressions and clicks.
- Tournaments: drafts hidden, registration window, rating limits, members
  only, entry fee charged with the seat and refunded on withdraw or cancel,
  prizes paid once at the end, announcements and the live page link, a
  paused state, withdrawn players keep their opponents' points.
- Draws and losses are paid; the battle pass counts per season; the shop
  respects availability; the game keeps its own art (the manager cannot
  change logos, tab icons or bot portraits).

Fixes
- Realtime channels get the refreshed token and rejoin when dropped; a chat
  that failed its first load keeps trying; no vibration before the first tap;
  org and redeem notices in the bell; Dockerfile adds intl and gd.

Data: Arab countries and regions dataset for tournament forms. Docs: redeem
API, tournament hosting spec.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 6ce8f229
......@@ -36,3 +36,6 @@ tests/rpc.log
# Written by the branding admin on save; the real store is Supabase
public/assets/brand/theme.json
public/js/core/i18n.js.lock
# Python bytecode from tools/*.py
__pycache__/
......@@ -998,6 +998,19 @@
"text-secondary",
"win"
],
"asset_slots": [
{
"slot": "share_invite_card",
"size": "1200x630",
"admin": "/admin/branding.php (Visual Assets)",
"used_by": [
"./api/share-card.php",
"./includes/link-preview.php"
],
"fallback": "drawn by api/share-card.php; without GD, ./public/assets/game/share-card.png",
"purpose": "og:image for invite links with no organisation, unknown codes and the home page"
}
],
"totals": {
"emojis": 78,
"colors": 84,
......@@ -1005,4 +1018,4 @@
"animations": 26,
"css_variables": 57
}
}
\ No newline at end of file
}
FROM php:8.3-apache
RUN apt-get update && apt-get install -y libpq-dev libcurl4-openssl-dev \
&& docker-php-ext-install pdo pdo_pgsql pgsql curl \
# intl: Normalizer (NFKC) for redeem-code canonicalisation, so includes/redeem.php
# matches public.redeem_canonical exactly (docs/redeem-contract.md 5.2).
# gd with FreeType, JPEG, PNG and WebP: api/share-card.php draws the 1200x630
# link-preview cards (crest, name, Arabic shaped in PHP).
RUN apt-get update && apt-get install -y libpq-dev libcurl4-openssl-dev libicu-dev \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libwebp-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install pdo pdo_pgsql pgsql curl intl gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN echo "upload_max_filesize = 10M\npost_max_size = 12M\nmax_file_uploads = 5" > /usr/local/etc/php/conf.d/uploads.ini
......
......@@ -683,6 +683,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
['slot' => 'splash_bg', 'label' => 'Splash Background', 'w' => 1080, 'h' => 1920, 'hint' => 'Full background on app launch'],
['slot' => 'favicon', 'label' => 'Favicon', 'w' => 32, 'h' => 32, 'hint' => 'Browser tab icon'],
['slot' => 'default_avatar', 'label' => 'Default Player Avatar', 'w' => 128, 'h' => 128, 'hint' => 'Shown if player has no photo'],
['slot' => 'share_invite_card', 'label' => 'Invite Share Card', 'w' => 1200, 'h' => 630, 'hint' => 'Link preview (og:image) for invite links with no organisation and for the home page. PNG, 1200x630. Empty: the drawn El3ab card'],
];
foreach ($assetSlots as $a):
$current = $theme['assets'][$a['slot']] ?? null;
......
<?php
/**
* Ads staff run from the manager (ad_campaigns), for the banner on the play
* table (public/js/core/ad-banner.js).
*
* get {slot, game} the campaign to show here, or {"error":"no_ads"}
* impression {campaign_id} the banner was on screen (the app sends one per view)
* click {campaign_id} the player opened it
*
* A campaign is shown only while it is live: status 'active', inside its
* starts_at / ends_at, with budget left (budget_total counts billable events,
* see migrations/2026-10-26-ad-tracking.sql; 0 means no cap), and targeted at
* this slot and game (an empty target list means every slot or every game).
* Among the live campaigns the highest priority wins; ties rotate by weight.
*
* budget_daily is not enforced: nothing records spend per day yet.
*/
header('Content-Type: application/json');
header('Cache-Control: no-store');
require_once __DIR__ . '/../includes/cors.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
$db = supabaseService();
/** Postgres text[] as PHP array, whichever way PostgREST hands it over. */
function adList($v): array {
if (is_array($v)) return array_values(array_filter(array_map('strval', $v), 'strlen'));
if (is_string($v)) return array_values(array_filter(array_map(fn($s) => trim($s, ' "'), explode(',', trim($v, '{}'))), 'strlen'));
return [];
}
/** Who is looking, for the per-viewer throttle: the player, else the address. */
function adViewerKey(): string {
$token = getAuthToken();
if ($token) {
$parts = explode('.', $token);
if (count($parts) === 3) {
$p = json_decode(base64UrlDecode($parts[1]), true);
if (!empty($p['sub'])) return 'u:' . $p['sub'];
}
}
$fwd = trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]);
return 'ip:' . ($fwd !== '' ? $fwd : ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
}
/** True at most once per $seconds for this key, across processes. */
function adThrottle(string $key, int $seconds): bool {
$name = 'el3ab_ad_' . hash('sha256', $key);
if (function_exists('apcu_add')) return apcu_add($name, time(), $seconds);
$file = sys_get_temp_dir() . '/' . $name;
$last = is_file($file) ? (int)@file_get_contents($file) : 0;
if (time() - $last < $seconds) return false;
@file_put_contents($file, (string)time(), LOCK_EX);
return true;
}
switch ($action) {
case 'get':
$slot = $input['slot'] ?? 'banner_top';
$game = $input['game'] ?? '';
$slot = (string)($input['slot'] ?? 'banner_top');
$game = (string)($input['game'] ?? '');
$now = gmdate('Y-m-d\TH:i:s\Z');
$params = [
'select' => 'id,name,image_url,click_url,target_slots',
$campaigns = $db->get('ad_campaigns', [
'select' => 'id,name,name_ar,image_url,click_url,target_slots,target_games,priority,weight,budget_total,spent_total',
'status' => 'eq.active',
'and' => "(or(starts_at.is.null,starts_at.lte.{$now}),or(ends_at.is.null,ends_at.gt.{$now}))",
'order' => 'priority.desc,created_at.desc',
'limit' => '5'
];
$campaigns = $db->get('ad_campaigns', $params);
if (empty($campaigns) || isset($campaigns['error'])) {
'limit' => '50',
]);
if (!is_array($campaigns) || isset($campaigns['error'])) {
echo json_encode(['error' => 'no_ads']);
exit;
}
// Filter by slot and game targeting
$matched = null;
$live = [];
foreach ($campaigns as $c) {
$slots = $c['target_slots'] ?? [];
if (is_string($slots)) {
$slots = array_filter(explode(',', trim($slots, '{}')));
}
if (!empty($slots) && !in_array($slot, $slots)) continue;
$games = $c['target_games'] ?? [];
if (is_string($games)) {
$games = array_filter(explode(',', trim($games, '{}')));
}
if (!empty($games) && $game && !in_array($game, $games)) continue;
$matched = $c;
break;
if (trim((string)($c['image_url'] ?? '')) === '') continue;
$budget = (int)($c['budget_total'] ?? 0);
if ($budget > 0 && (int)($c['spent_total'] ?? 0) >= $budget) continue;
$slots = adList($c['target_slots'] ?? []);
if ($slots && !in_array($slot, $slots, true)) continue;
$games = adList($c['target_games'] ?? []);
if ($games && !in_array($game, $games, true)) continue;
$live[] = $c;
}
if (!$matched) {
if (!$live) {
echo json_encode(['error' => 'no_ads']);
exit;
}
// The top priority wins; campaigns sharing it take turns by weight.
$top = max(array_map(fn($c) => (int)($c['priority'] ?? 0), $live));
$pool = array_values(array_filter($live, fn($c) => (int)($c['priority'] ?? 0) === $top));
$total = array_sum(array_map(fn($c) => max(1, (int)($c['weight'] ?? 100)), $pool));
$roll = random_int(1, max(1, $total));
$chosen = $pool[0];
foreach ($pool as $c) {
$roll -= max(1, (int)($c['weight'] ?? 100));
if ($roll <= 0) { $chosen = $c; break; }
}
$click = trim((string)($chosen['click_url'] ?? ''));
echo json_encode([
'id' => $matched['id'],
'image_url' => $matched['image_url'] ?? '',
'click_url' => $matched['click_url'] ?? '',
'id' => $chosen['id'],
'name' => $chosen['name_ar'] ?: ($chosen['name'] ?? ''),
'image_url' => $chosen['image_url'],
// Only a web address opens; anything else stays a picture.
'click_url' => preg_match('#^https?://#i', $click) ? $click : '',
], JSON_UNESCAPED_UNICODE);
break;
case 'impression':
$campaignId = $input['campaign_id'] ?? null;
if ($campaignId) {
$campaign = $db->get('ad_campaigns', ['id' => "eq.{$campaignId}", 'select' => 'id,impressions']);
if (!empty($campaign) && !isset($campaign['error']) && isset($campaign[0])) {
$current = (int)($campaign[0]['impressions'] ?? 0);
$db->update('ad_campaigns', ['impressions' => $current + 1], ['id' => "eq.{$campaignId}"]);
}
case 'click':
$campaignId = (string)($input['campaign_id'] ?? '');
if (!preg_match('/^[0-9a-f-]{36}$/i', $campaignId)) {
echo json_encode(['ok' => false, 'error' => 'campaign_id required']);
exit;
}
// One count per viewer per campaign in a short window, so a reload loop
// or a double tap cannot run up a sponsor's numbers.
$window = $action === 'impression' ? 20 : 10;
if (!adThrottle($action . ':' . $campaignId . ':' . adViewerKey(), $window)) {
echo json_encode(['ok' => true, 'counted' => false]);
exit;
}
$res = $db->rpc('ad_track', ['p_campaign_id' => $campaignId, 'p_event' => $action]);
if (isset($res['error'])) {
error_log('[ads] track failed: ' . $res['error']);
echo json_encode(['ok' => false]);
exit;
}
echo json_encode(['ok' => true]);
echo json_encode(['ok' => true, 'counted' => !empty($res['ok'])]);
break;
default:
......
......@@ -33,6 +33,9 @@ switch ($action) {
case 'delete-account':
handleDeleteAccount($input);
break;
case 'upgrade-guest':
handleUpgradeGuest($input);
break;
default:
jsonError('Invalid action', 400);
}
......@@ -62,6 +65,10 @@ function handleSignup(array $input): void {
]);
if (isset($result['error'])) {
if (in_array($result['error_code'] ?? '', ['user_already_exists', 'email_exists'], true)) {
jsonResponse(['error' => $result['error'], 'code' => 'email_exists',
'message' => 'الإيميل ده عليه حساب. سجل دخول بيه'], $result['code'] ?? 422);
}
jsonError($result['error'], $result['code'] ?? 400);
}
......@@ -75,7 +82,8 @@ function handleSignup(array $input): void {
jsonResponse([
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'],
'user' => $result['user']
'user' => $result['user'],
'redeem' => signupRedeem($result['user']['id'], $input),
]);
}
......@@ -259,9 +267,6 @@ function handleDeleteAccount(array $input): void {
$sdb->delete('chat_messages', ['sender_id' => 'eq.' . $userId]);
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
$sdb->delete('player_blocks', ['or' => "(player_id.eq.{$userId},blocked_id.eq.{$userId})"]);
$sdb->delete('daily_claims', ['user_id' => 'eq.' . $userId]);
$sdb->delete('challenge_progress', ['user_id' => 'eq.' . $userId]);
$sdb->delete('achievement_progress', ['user_id' => 'eq.' . $userId]);
$sdb->delete('group_members', ['user_id' => 'eq.' . $userId]);
$sdb->delete('profiles', ['id' => 'eq.' . $userId]);
......@@ -284,3 +289,150 @@ function handleDeleteAccount(array $input): void {
jsonResponse(['success' => true]);
}
/**
* The code sent with a signup (docs/redeem-api.md 2.1). Applied after the
* account exists; a bad code never blocks the account, it comes back as an
* error block the client shows under the form.
*/
function signupRedeem(string $userId, array $input): ?array {
$code = trim((string)($input['redeem_code'] ?? ''));
if ($code === '') return null;
require_once __DIR__ . '/../includes/redeem.php';
$source = in_array($input['redeem_source'] ?? '', ['link', 'qr'], true) ? $input['redeem_source'] : 'signup';
try {
$r = redeemApply($userId, $code, $source, redeemIpHash(), redeemDeviceId($input['device_id'] ?? null), ['guest_groups' => false]);
} catch (Throwable $e) {
error_log('[auth] signup redeem failed: ' . $e->getMessage());
$r = ['ok' => false, 'error' => 'server_error'];
}
if (!empty($r['ok'])) return $r;
return el3abErrorBody($r, 'redeem') + ['keep_code' => redeemKeepCode((string)($r['error'] ?? ''))];
}
function upgradeFail(string $key, int $status, array $extra = []): void {
require_once __DIR__ . '/../includes/redeem.php';
jsonResponse(el3abErrorBody(['error' => $key] + $extra, 'auth'), $status);
}
/** GoTrue's own view of a user (service key): is_anonymous lives there. */
function authAdminUser(string $userId): ?array {
$ch = curl_init(SUPABASE_AUTH . '/admin/users/' . $userId);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['apikey: ' . SUPABASE_SERVICE_KEY, 'Authorization: Bearer ' . SUPABASE_SERVICE_KEY],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$u = json_decode((string)$raw, true);
return $code === 200 && is_array($u) && !empty($u['id']) ? $u : null;
}
/**
* Convert the caller's GoTrue anonymous user into a permanent account in place
* (design 2.6 D): same user id, so coins, history, groups, applications and
* held codes all stay. GOTRUE_MAILER_AUTOCONFIRM is on and GoTrue 2.186
* accepts email and password for an anonymous user in one PUT /user; if a
* different GoTrue ever answers with the email unset, the email and the
* password are sent again one at a time.
*/
function handleUpgradeGuest(array $input): void {
require_once __DIR__ . '/../includes/redeem.php';
$token = getAuthToken();
if (!$token) upgradeFail('account_required', 401);
$user = verifyToken($token);
if (!$user || empty($user['id'])) jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401);
assertNotBanned($user['id']);
$userId = $user['id'];
$email = strtolower(trim((string)($input['email'] ?? '')));
$password = (string)($input['password'] ?? '');
$username = trim((string)($input['username'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) upgradeFail('invalid_email', 422);
if (strlen($password) < 6) upgradeFail('weak_password', 422);
if (!preg_match('/^[a-zA-Z0-9\x{0600}-\x{06FF}_.-]{3,20}$/u', $username)) upgradeFail('invalid_username', 422);
$before = authAdminUser($userId);
if (!$before) upgradeFail('upgrade_failed', 502);
if (empty($before['is_anonymous'])) upgradeFail('not_guest', 409);
$sdb = supabaseService();
$taken = $sdb->get('profiles', ['username' => 'eq.' . $username, 'id' => 'neq.' . $userId, 'select' => 'id', 'limit' => 1]);
if (is_array($taken) && !isset($taken['error']) && $taken) upgradeFail('username_taken', 409);
$put = supabaseAuth('PUT', 'user', ['email' => $email, 'password' => $password, 'data' => ['username' => $username]], $token);
if (isset($put['error'])) {
$ec = (string)($put['error_code'] ?? '');
if (in_array($ec, ['email_exists', 'user_already_exists'], true) || stripos($put['error'], 'already been registered') !== false) {
upgradeFail('email_exists', 409, ['keep_code' => true]);
}
if ($ec === 'weak_password' || preg_match('/password should/i', $put['error'])) upgradeFail('weak_password', 422);
if ($ec === 'email_address_invalid' || $ec === 'validation_failed') upgradeFail('invalid_email', 422);
error_log('[auth] upgrade-guest PUT failed: ' . json_encode($put));
upgradeFail('upgrade_failed', 502);
}
if (strtolower((string)($put['email'] ?? '')) !== $email) {
$put = supabaseAuth('PUT', 'user', ['email' => $email], $token);
if (isset($put['error']) || strtolower((string)($put['email'] ?? '')) !== $email) upgradeFail('upgrade_failed', 502);
}
// A fresh session: the guest's token still says is_anonymous.
$session = supabaseAuth('POST', 'token?grant_type=password', ['email' => $email, 'password' => $password]);
if (isset($session['error']) || empty($session['access_token'])) {
supabaseAuth('PUT', 'user', ['password' => $password], $token);
$session = supabaseAuth('POST', 'token?grant_type=password', ['email' => $email, 'password' => $password]);
}
if (isset($session['error']) || empty($session['access_token'])) {
error_log('[auth] upgrade-guest login failed: ' . json_encode($session));
upgradeFail('upgrade_failed', 502);
}
if (($session['user']['id'] ?? '') !== $userId) {
error_log('[auth] upgrade-guest changed the user id: ' . $userId . ' -> ' . ($session['user']['id'] ?? '?'));
upgradeFail('upgrade_failed', 502);
}
// The profile: the chosen username, and the name too while it is still the guest default.
$p = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'id,display_name', 'limit' => 1]);
$current = is_array($p) && !isset($p['error']) && $p ? (string)$p[0]['display_name'] : '';
$patch = ['username' => $username];
if ($current === '' || in_array($current, ['ضيف', 'Player', 'لاعب جديد'], true) || str_starts_with($current, 'guest_')) {
$patch['display_name'] = $username;
}
$sdb->update('profiles', $patch, ['id' => 'eq.' . $userId]);
// Codes: the one sent now, then every code held as a guest.
$ip = redeemIpHash();
$device = redeemDeviceId($input['device_id'] ?? null);
$redeem = null;
$skip = [];
$code = trim((string)($input['redeem_code'] ?? ''));
if ($code !== '') {
try {
$r = redeemApply($userId, $code, 'guest_upgrade', $ip, $device, ['guest_groups' => false]);
} catch (Throwable $e) {
error_log('[auth] upgrade redeem failed: ' . $e->getMessage());
$r = ['ok' => false, 'error' => 'server_error'];
}
$redeem = !empty($r['ok']) ? $r : el3abErrorBody($r, 'redeem') + ['keep_code' => redeemKeepCode((string)($r['error'] ?? ''))];
if (!empty($r['code']['id'])) $skip[] = $r['code']['id'];
}
$intents = redeemApplyIntents($userId, $ip, $device, $skip);
if ($redeem === null) {
foreach ($intents as $line) {
if (!empty($line['ok'])) { $redeem = $line['result']; break; }
}
}
$profile = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => '*', 'limit' => 1]);
jsonResponse([
'ok' => true,
'upgraded' => true,
'access_token' => $session['access_token'],
'refresh_token' => $session['refresh_token'],
'user' => $session['user'],
'profile' => is_array($profile) && !isset($profile['error']) && $profile ? $profile[0] : null,
'redeem' => $redeem,
'intents' => array_map(fn($l) => array_diff_key($l, ['result' => 1, 'code_id' => 1]), $intents),
]);
}
......@@ -532,11 +532,12 @@ function handleComplete(string $userId, array $input): void {
$sdb->delete('backgammon_queue', ['match_id' => 'eq.' . $matchId]);
if (empty($claimed)) jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
if ($tooQuick) jsonResponse(['success' => true, 'too_short' => true, 'rewards' => []]);
// Bot ids map to null: the function skips nulls, and a 'bot_' id fails its uuid cast.
// The player alone, with their result: a loss to the bot is paid
// backgammon_loss_* (it used to be paid nothing).
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => [$winner === $userId ? $userId : null],
'p_winners' => [['id' => $userId, 'result' => $winner === $userId ? 'win' : 'loss']],
'p_reason' => $reason
]);
jsonResponse($result ?: ['success' => true, 'rewards' => []]);
......@@ -561,10 +562,19 @@ function handleComplete(string $userId, array $input): void {
// p_winners is jsonb: pass the array. json_encode() sent a JSON string and
// the function failed with "cannot get array length of a scalar".
// Every seated player with their result, so the loser is paid
// backgammon_loss_* too. No known winner pays nobody, as before.
$paid = [];
if ($winner) {
$paid[] = ['id' => $winner, 'result' => 'win'];
foreach ($players as $p) {
if (is_string($p) && $p !== $winner && !str_starts_with($p, 'bot_')) $paid[] = ['id' => $p, 'result' => 'loss'];
}
}
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => [$winner],
'p_winners' => $paid,
'p_reason' => $reason
]);
......
......@@ -40,6 +40,7 @@ function getBattlePass($db, string $userId): void {
'xp_per_tier' => intval($s['xp_per_tier'] ?? 100)
];
} else {
$s = null;
// Fallback hardcoded
$season = [
'id' => 'season_1',
......@@ -52,10 +53,10 @@ function getBattlePass($db, string $userId): void {
];
}
// Get player XP
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'xp,level', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['xp' => 0];
$totalXp = $profile['xp'] ?? 0;
// XP earned in this season (season_xp: profiles.xp minus the player's
// baseline for the season), not lifetime XP: a new season starts every
// player at tier 0. The hard-coded fallback season has no row, so no XP.
$totalXp = !empty($s) ? seasonXp($sdb, (string)$season['id'], $userId) : 0;
// Calculate current tier
$currentTier = min(intval($totalXp / $season['xp_per_tier']), $season['total_tiers']);
......@@ -87,6 +88,16 @@ function getBattlePass($db, string $userId): void {
]);
}
/** XP earned in a season; records the player's baseline the first time (migrations/2026-10-26-season-progress.sql). */
function seasonXp($sdb, string $seasonId, string $userId): int {
$res = $sdb->rpc('season_xp', ['p_season_id' => $seasonId, 'p_player_id' => $userId]);
if (isset($res['error'])) {
error_log('[battlepass] season_xp failed: ' . $res['error']);
return 0;
}
return max(0, (int)($res['result'] ?? 0));
}
function generateTierReward(int $tier): array {
$freeRewards = [
['type' => 'coins', 'amount' => 50],
......@@ -123,8 +134,8 @@ function claimTier($db, string $userId, array $input): void {
$xpPerTier = max(1, intval($s['xp_per_tier'] ?? 100));
if ($tier > $totalTiers) jsonError('Invalid tier');
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'xp', 'limit' => 1]);
$xp = intval($profiles[0]['xp'] ?? 0);
// Unlocked by this season's XP, the same number the pass shows.
$xp = seasonXp($sdb, (string)$s['id'], $userId);
if ($tier > intdiv($xp, $xpPerTier)) jsonError('Tier not unlocked yet', 403);
$reason = 'Battle pass ' . $s['id'] . ' tier ' . $tier;
......
<?php
/**
* What staff switch on and off in the manager, for the app to read at boot
* (public/js/core/config.js).
*
* features flag id => on/off, from feature_flags (id, is_enabled). The
* old query asked for feature_flags.key, which does not exist,
* so this answered 42703 and features was always empty.
* rollout flag id => percentage, only for flags rolled out to part of
* the players (the app buckets by player id).
* maintenance maintenance_mode, plus an optional message staff can put in
* the flag's conditions: {"message_ar": "...", "message": "...",
* "until": "2026-10-27T10:00:00Z"}.
* games every game_plugins row, in the manager's order, with whether
* it is enabled and its config (domino target scores, ludo bot
* limits, ...). Disabled games are included so the app knows
* to hide them rather than guessing.
*
* Public: nothing here is per player. Each answer is computed fresh; it is two
* small reads and the app asks once per launch.
*/
header('Content-Type: application/json');
header('Cache-Control: no-store');
require_once __DIR__ . '/../includes/cors.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$flags = $db->get('feature_flags', ['select' => 'key,value,is_enabled']);
$plugins = $db->get('game_plugins', ['select' => '*', 'is_enabled' => 'eq.true']);
$flags = $db->get('feature_flags', ['select' => 'id,is_enabled,rollout_percentage,conditions']);
$plugins = $db->get('game_plugins', [
'select' => 'game_key,name,name_ar,is_enabled,is_beta,sort_order,min_players,max_players,supports_ranked,supports_tournament,supports_bot,config',
'order' => 'sort_order.asc,game_key.asc',
]);
$flagsOk = is_array($flags) && !isset($flags['error']);
$pluginsOk = is_array($plugins) && !isset($plugins['error']);
$response = [
'features' => [],
'rollout' => (object)[],
'maintenance' => ['on' => false],
'games' => [],
'version' => '1.0.0'
// The app keeps its own defaults for whatever could not be read, instead of
// reading "no rows" as "everything is off".
'ok' => ['features' => $flagsOk, 'games' => $pluginsOk],
'version' => '1.1.0',
];
if (is_array($flags) && !isset($flags['error'])) {
if ($flagsOk) {
$rollout = [];
foreach ($flags as $f) {
if ($f['is_enabled'] ?? false) {
$response['features'][$f['key']] = $f['value'] ?? true;
$id = (string)($f['id'] ?? '');
if ($id === '') continue;
$on = (bool)($f['is_enabled'] ?? false);
$response['features'][$id] = $on;
$pct = (int)($f['rollout_percentage'] ?? 100);
if ($on && $pct < 100) $rollout[$id] = max(0, $pct);
if ($id === 'maintenance_mode') {
$c = $f['conditions'] ?? [];
if (is_string($c)) $c = json_decode($c, true);
$c = is_array($c) ? $c : [];
$response['maintenance'] = [
'on' => $on,
'message_ar' => isset($c['message_ar']) ? (string)$c['message_ar'] : null,
'message' => isset($c['message']) ? (string)$c['message'] : null,
'until' => isset($c['until']) ? (string)$c['until'] : null,
];
}
}
$response['rollout'] = (object)$rollout;
if (!$response['features']) $response['features'] = (object)[];
} else {
$response['features'] = (object)[];
}
if (is_array($plugins) && !isset($plugins['error'])) {
$response['games'] = $plugins;
if ($pluginsOk) {
foreach ($plugins as $p) {
$config = $p['config'] ?? [];
if (is_string($config)) $config = json_decode($config, true);
$response['games'][] = [
'key' => $p['game_key'],
'name' => $p['name'] ?? null,
'name_ar' => $p['name_ar'] ?? null,
'enabled' => (bool)($p['is_enabled'] ?? false),
'beta' => (bool)($p['is_beta'] ?? false),
'order' => (int)($p['sort_order'] ?? 0),
'min_players' => isset($p['min_players']) ? (int)$p['min_players'] : null,
'max_players' => isset($p['max_players']) ? (int)$p['max_players'] : null,
'supports_ranked' => (bool)($p['supports_ranked'] ?? true),
'supports_tournament' => (bool)($p['supports_tournament'] ?? true),
'supports_bot' => (bool)($p['supports_bot'] ?? false),
'config' => is_array($config) ? (object)$config : (object)[],
];
}
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
......@@ -61,8 +61,10 @@ $NO_SHOW_SECONDS = tournamentNoShowSeconds();
// ---------------------------------------------------------------------------
// 1. Start tournaments that are due
// ---------------------------------------------------------------------------
// Drafts are unpublished and never start on their own; staff start one
// explicitly through tournament-admin.php.
$dueFilter = [
'status' => 'in.(' . TOURNAMENT_STATUS_REGISTRATION . ',' . TOURNAMENT_STATUS_DRAFT . ')',
'status' => 'eq.' . TOURNAMENT_STATUS_REGISTRATION,
'auto_start' => 'is.true',
'select' => 'id,name,starts_at,min_players',
'limit' => 50,
......@@ -180,6 +182,22 @@ if (is_array($running) && !isset($running['error'])) {
}
}
// ---------------------------------------------------------------------------
// 3. Entry fees owed back: cancelled tournaments (the manager cancels by
// setting the status, nothing else refunds them) and entries cancelled by
// staff before the start. Idempotent: a refunded entry holds 0.
// ---------------------------------------------------------------------------
$refunds = tournamentRefundSweep($db, 0);
if (!empty($refunds['refunds'])) $report['refunded'] = $refunds;
// ---------------------------------------------------------------------------
// 4. Redeem attempt log: keep 30 days (docs/redeem-contract.md 3.3). Hourly.
// ---------------------------------------------------------------------------
require_once __DIR__ . '/../includes/redeem.php';
if (tournamentThrottle('redeem_prune', 3600)) {
$report['redeem_attempts_pruned'] = redeemPruneAttempts(30);
}
echo json_encode($report, JSON_UNESCAPED_UNICODE);
/**
......@@ -207,11 +225,11 @@ function cronFinaliseMatch($db, array $match, array $derived, string $zeroClockC
if (isset($written['error']) || empty($written)) return false;
$winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
// Both players with their result (game.php finaliseMatch does the same).
supabaseRpc('complete_match', [
'p_game_key' => $match['game_key'] ?? 'chess',
'p_match_id' => $match['id'],
'p_winners' => $winnerId ? [$winnerId] : [],
'p_winners' => chessPayoutPlayers($match, $derived),
'p_reason' => $derived['reason'],
]);
......
......@@ -7,22 +7,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/game-config.php';
$token = requireAuth();
$userId = getUserId($token);
$method = $_SERVER['REQUEST_METHOD'];
$db = supabase($token);
// Service client throughout: $userId comes from the verified token, and the
// profile's coins, streak and claim time are columns a player token may not
// write (the profiles guard trigger rejects them).
$sdb = supabaseService();
$DAY_REWARDS = [50, 75, 100, 125, 150, 200, 300];
// Coins per streak day, from reward_config daily_reward_day_1..7 (the manager
// edits them); the old hard-coded ladder is the default for any missing day.
$DAY_REWARDS = dailyRewardLadder();
if ($method === 'GET') {
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
if (!$profile) {
ensureProfile($sdb, $userId, $token);
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
if (!$profile) jsonError('Profile not found');
}
......@@ -47,7 +52,8 @@ if ($method === 'GET') {
'already_claimed' => $alreadyClaimed,
'today_reward' => $todayReward,
'day_index' => $dayIndex,
'last_claim' => $lastClaim
'last_claim' => $lastClaim,
'rewards' => $DAY_REWARDS
]);
}
......@@ -57,11 +63,11 @@ if ($method === 'POST') {
if ($action !== 'claim') jsonError('Invalid action');
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
if (!$profile) {
ensureProfile($sdb, $userId, $token);
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'daily_streak,last_daily_reward,coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
if (!$profile) jsonError('Profile not found');
}
......@@ -93,12 +99,21 @@ if ($method === 'POST') {
$currentCoins = $profile['coins'] ?? 0;
$newCoins = $currentCoins + $reward;
// Update profile: coins, streak, last_daily_reward
$sdb->update('profiles', [
// Update profile: coins, streak, last_daily_reward. Conditional on the
// claim time still being the one read above, so two taps racing each other
// pay once: the second finds the row changed and updates nothing. (A plain
// filter: with an `or=` filter PostgREST updated the row and still answered
// an empty list, which read as "already claimed".)
$claimed = $sdb->update('profiles', [
'coins' => $newCoins,
'daily_streak' => $streak,
'last_daily_reward' => $now
], ['id' => 'eq.' . $userId]);
], [
'id' => 'eq.' . $userId,
'last_daily_reward' => $lastClaim ? 'eq.' . $lastClaim : 'is.null',
]);
if (isset($claimed['error'])) jsonError('Could not claim right now', 502);
if (empty($claimed)) jsonError('Already claimed today');
// Record transaction
$sdb->insert('economy_transactions', [
......@@ -117,7 +132,8 @@ if ($method === 'POST') {
'coins' => $reward,
'total_coins' => $newCoins,
'streak' => $streak,
'day_index' => $dayIndex
'day_index' => $dayIndex,
'rewards' => $DAY_REWARDS
]);
}
......
......@@ -11,10 +11,14 @@ require_once __DIR__ . '/../includes/match-presence.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
require_once __DIR__ . '/../includes/ghost.php';
require_once __DIR__ . '/../includes/game-config.php';
/** Seconds a game against a bot must last before it pays (see finaliseMatch).
/**
* Seconds a game against a bot must last before it pays (see finaliseMatch).
* Declared before the dispatch below: a top-level const further down the file
* is not defined yet when the handler runs. */
* is not defined yet when a handler runs, so every bot game's resign or
* finish died with "Undefined constant" and was never closed or paid.
*/
const CHESS_BOT_MIN_PAID_SECONDS = 60;
$token = requireAuth();
......@@ -56,7 +60,6 @@ switch ($action) {
jsonError('Invalid action');
}
function chessBotTooShort(?string $startedAt): bool {
$ts = strtotime((string) $startedAt);
return !$ts || $ts > time() - CHESS_BOT_MIN_PAID_SECONDS;
......@@ -211,8 +214,9 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu
return false; // Somebody else closed it first; not an error, just not ours.
}
$winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
$winners = $winnerId ? [$winnerId] : [];
// Both players, each with their result (win / loss / draw), so losses and
// draws are paid from reward_config too; this used to send the winner only.
$winners = chessPayoutPlayers($match, $derived);
// A bot-fill match's queue row (kept a moment after the hand-off) is done.
if ($isGhost) $sdb->delete('matchmaking_queue', ['match_id' => 'eq.' . $matchId]);
......@@ -720,15 +724,11 @@ function applyRatings($sdb, array $match, array $derived): void {
$won = $score === 1.0;
$lost = $score === 0.0;
// The rating only. Games, wins, losses, draws and streaks are counted
// by complete_match(), which now gets both players with their result;
// counting them here as well counted every rated game twice.
$sdb->update('profiles', [
$column => $after,
'games_played' => (int)($prof['games_played'] ?? 0) + 1,
'total_games_played' => (int)($prof['total_games_played'] ?? 0) + 1,
'total_wins' => (int)($prof['total_wins'] ?? 0) + ($won ? 1 : 0),
'total_losses' => (int)($prof['total_losses'] ?? 0) + ($lost ? 1 : 0),
'total_draws' => (int)($prof['total_draws'] ?? 0) + (!$won && !$lost ? 1 : 0),
'win_streak' => $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0,
'best_win_streak' => max((int)($prof['best_win_streak'] ?? 0), $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0),
$column => $after,
], ['id' => 'eq.' . $pid]);
$sdb->insert('rating_history', [
......@@ -747,11 +747,13 @@ function applyRatings($sdb, array $match, array $derived): void {
}
}
/** FIDE-style K: smaller as a player's rating rises. */
/**
* FIDE-style K: smaller as a player's rating rises. The steps and the K values
* come from reward_config (elo_k_*, edited in the manager); 32 / 24 above 2000
* / 16 above 2400 are the defaults, the numbers this always used.
*/
function eloKFactor(int $rating): int {
if ($rating > 2400) return 16;
if ($rating > 2000) return 24;
return 32;
return eloKFactorFromConfig($rating);
}
function calculateElo(int $playerRating, int $opponentRating, float $score): int {
......@@ -916,11 +918,13 @@ function handleFindActiveMatch(string $userId, array $input): void {
}
// Check ludo matches
// ludo_matches has no started_at (the filter answered 42703, so a ludo game
// was never found to watch); last_activity is what a live table updates.
$ludo = $sdb->get('ludo_matches', [
'status' => 'eq.in_progress',
'started_at' => 'gte.' . $since,
'last_activity' => 'gte.' . $since,
'select' => 'id,players',
'order' => 'started_at.desc',
'order' => 'last_activity.desc',
'limit' => 10
]);
if (is_array($ludo) && !isset($ludo['error'])) {
......
......@@ -5,9 +5,9 @@
* GET ?action=list my groups, unread and last line
* GET ?action=detail&group_id= the group, members (with presence), my role
* GET ?action=chat-history&group_id=[&after=|&before=][&mark_read=0]
* GET ?action=preview&code= what an invite code opens
* GET ?action=preview&code= what a code opens (any code, see below)
* POST {action:'create', name, member_ids[]}
* POST {action:'join', code}
* POST {action:'join', code} any code, see below
* POST {action:'add-member'|'remove-member', group_id, user_id} owner/admin
* POST {action:'set-role', group_id, user_id, role:'admin'|'member'} owner
* POST {action:'update', group_id, name} owner/admin
......@@ -22,6 +22,11 @@
* not read and are gone.
*
* Every chat message carries `mine`, decided here from the token.
*
* preview and join resolve codes through redeem_code (includes/redeem.php), so
* one code box takes a group code, an org invite or a promo code alike, and
* both sit behind the shared database rate limiter. Group codes keep their old
* answers; other codes add a `redeem` block (docs/redeem-api.md 9.1).
*/
header('Content-Type: application/json');
......@@ -32,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/social.php';
require_once __DIR__ . '/../includes/redeem.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -119,6 +125,42 @@ function cleanCode($c): string {
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$c));
}
/**
* A failed code, in the shapes the groups sheet already reads (error text and
* code), plus redeem_error (the redeem key) and the Arabic message.
*/
function groupCodeFail(array $r): void {
$b = el3abErrorBody($r, 'redeem');
$key = $b['error'];
$legacy = [
'not_found' => ['No group has this code', 'bad_code', 404],
'invalid_format' => ['No group has this code', 'bad_code', 404],
'group_not_found' => ['No group has this code', 'bad_code', 404],
'group_full' => ['Group is full', 'full', 409],
'blocked' => ['Cannot join this group', 'blocked', 403],
'rate_limited' => ['Too many attempts', 'rate_limited', 429],
];
[$text, $code, $status] = $legacy[$key] ?? [$key, $key, el3abHttpStatus($key)];
if ($key === 'rate_limited' && !empty($b['retry_after'])) header('Retry-After: ' . (int)$b['retry_after']);
$out = ['error' => $text, 'code' => $code, 'redeem_error' => $key, 'message' => $b['message']];
foreach (['retry_after', 'limit', 'org', 'orgs', 'rule', 'value', 'starts_at', 'expires_at', 'preview'] as $k) {
if (isset($b[$k])) $out[$k] = $b[$k];
}
jsonResponse($out, $status);
}
/** The group a redeem answer joined or points at (its group_join grant), or null. */
function groupOfRedeem($sdb, array $r): ?array {
foreach (($r['results'] ?? []) as $g) {
if (($g['type'] ?? '') === 'group_join' && !empty($g['group_id']) && ($g['ok'] ?? false)) return groupRow($sdb, $g['group_id']);
}
foreach (($r['grants'] ?? []) as $g) {
if (($g['type'] ?? '') === 'group_join' && !empty($g['group']['id'])) return groupRow($sdb, $g['group']['id']);
}
if (!empty($r['group']['id'])) return groupRow($sdb, $r['group']['id']);
return null;
}
if ($method === 'GET') {
$action = $_GET['action'] ?? '';
......@@ -252,18 +294,23 @@ if ($method === 'GET') {
}
if ($action === 'preview') {
$code = cleanCode($_GET['code'] ?? '');
if (strlen($code) < 4) jsonError('code required');
$rows = $sdb->get('groups', ['invite_code' => 'eq.' . $code, 'select' => GROUP_COLS, 'limit' => 1]);
if (!is_array($rows) || isset($rows['error']) || empty($rows)) jsonResponse(['error' => 'No group has this code', 'code' => 'bad_code'], 404);
$g = $rows[0];
$owner = profileOf($sdb, $g['owner_id']);
jsonResponse(['group' => [
'id' => $g['id'], 'name' => $g['name'], 'avatar_url' => $g['avatar_url'],
'member_count' => $g['member_count'], 'max_members' => $g['max_members'],
'owner_name' => playerName($owner),
'is_member' => (bool)membership($sdb, $g['id'], $userId),
]]);
$raw = (string)($_GET['code'] ?? '');
if (trim($raw) === '') jsonError('code required');
$r = redeemPreview($userId, $raw, 'redeem_screen', redeemIpHash(), null);
if (empty($r['ok'])) groupCodeFail($r);
if (($r['code']['kind'] ?? '') === 'group') {
$g = groupOfRedeem($sdb, $r);
if (!$g) groupCodeFail(['error' => 'not_found']);
$owner = profileOf($sdb, $g['owner_id']);
jsonResponse(['group' => [
'id' => $g['id'], 'name' => $g['name'], 'avatar_url' => $g['avatar_url'],
'member_count' => $g['member_count'], 'max_members' => $g['max_members'],
'owner_name' => playerName($owner),
'is_member' => (bool)membership($sdb, $g['id'], $userId),
]]);
}
// Any other code: what it opens, for the shared code box.
jsonResponse(['group' => groupOfRedeem($sdb, $r), 'redeem' => $r]);
}
jsonError('Invalid action');
......@@ -303,25 +350,20 @@ if ($method === 'POST') {
}
if ($action === 'join') {
$code = cleanCode($input['code'] ?? '');
if (strlen($code) < 4) jsonError('code required');
$rows = $sdb->get('groups', ['invite_code' => 'eq.' . $code, 'select' => GROUP_COLS, 'limit' => 1]);
if (!is_array($rows) || isset($rows['error']) || empty($rows)) jsonResponse(['error' => 'No group has this code', 'code' => 'bad_code'], 404);
$g = $rows[0];
if (membership($sdb, $g['id'], $userId)) jsonResponse(['group' => $g, 'already' => true]);
if (isBlockedEitherWay($sdb, $userId, $g['owner_id'])) jsonResponse(['error' => 'Cannot join this group', 'code' => 'blocked'], 403);
if ((int)$g['member_count'] >= (int)$g['max_members']) jsonResponse(['error' => 'Group is full', 'code' => 'full'], 409);
$r = $sdb->insert('group_members', ['group_id' => $g['id'], 'user_id' => $userId, 'role' => 'member']);
if (isset($r['error'])) jsonError('Join failed', 500);
$myName = playerName(profileOf($sdb, $userId));
systemLine($sdb, $g['id'], $userId, 'joined', ['name' => $myName]);
if ($g['owner_id'] !== $userId) {
socialNotify($sdb, $g['owner_id'], 'group_joined', $myName . ' انضم إلى ' . $g['name'], $myName . ' joined ' . $g['name'],
['group_id' => $g['id'], 'group' => $g['name'], 'name' => $myName, 'from_id' => $userId]);
$raw = (string)($input['code'] ?? '');
if (trim($raw) === '') jsonError('code required');
// redeem_code does the checks and the insert; redeemApply posts the
// chat line and tells the owner (guests included, as before).
$r = redeemApply($userId, $raw, 'redeem_screen', redeemIpHash(), redeemDeviceId($input['device_id'] ?? null));
if (empty($r['ok'])) groupCodeFail($r);
if (($r['code']['kind'] ?? '') === 'group' || (!empty($r['group']) && empty($r['redemption_id']))) {
$g = groupOfRedeem($sdb, $r);
if (!$g) groupCodeFail(['error' => 'not_found']);
if (($r['status'] ?? '') === 'already_member') jsonResponse(['group' => $g, 'already' => true]);
jsonResponse(['group' => $g, 'success' => true]);
}
$n = recount($sdb, $g['id']);
jsonResponse(['group' => array_merge($g, ['member_count' => $n]), 'success' => true]);
jsonResponse(['success' => true, 'redeem' => $r, 'group' => groupOfRedeem($sdb, $r), 'navigate' => $r['navigate'] ?? null],
redeemHttpFor($r));
}
if ($action === 'add-member') {
......@@ -383,8 +425,12 @@ if ($method === 'POST') {
if ($action === 'new-code') {
requireMember($sdb, $groupId, $userId, ['owner', 'admin']);
for ($i = 0; $i < 4; $i++) {
for ($i = 0; $i < 6; $i++) {
$code = strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
// Group codes share the redeem namespace after folding (B reads as
// 8): skip one that would collide with another group or a code.
$free = redeemRpc('redeem_vanity_check', ['p_input' => $code]);
if (isset($free['available']) && !$free['available'] && ($free['reason'] ?? '') === 'code_taken') continue;
$r = $sdb->update('groups', ['invite_code' => $code], ['id' => 'eq.' . $groupId]);
if (!isset($r['error'])) jsonResponse(['invite_code' => $code]);
}
......
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
set_exception_handler(function($e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage(), 'line' => $e->getLine()]);
exit;
});
set_error_handler(function($errno, $errstr, $errfile, $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
/**
* Legacy multipart application form (public/js/modules/profile/scenes/org-apply.js).
*
* A thin proxy onto orgs.php apply (includes/orgs.php orgApply). The proof now
* goes to the private org-proofs bucket and no URL is ever returned; staff open
* it through a signed link (orgs.php applications / application-proof).
* Remove once the client posts to orgs.php (docs/redeem-api.md 9.3).
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
......@@ -20,135 +14,23 @@ require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/orgs.php';
$token = requireAuth();
$userId = getUserId($token);
if (!$userId) jsonError('Invalid user', 401);
// Validate required fields
$orgId = $_POST['org_id'] ?? null;
$documentType = $_POST['document_type'] ?? null;
$notes = $_POST['notes'] ?? '';
if (!$orgId) jsonError('org_id is required');
if (!isUuid($orgId)) jsonError('org_id is required');
if (!$documentType) jsonError('document_type is required');
// Validate document_type
$validTypes = ['membership_card', 'id_card', 'receipt', 'other'];
if (!in_array($documentType, $validTypes)) {
jsonError('Invalid document_type. Allowed: ' . implode(', ', $validTypes));
}
// Check if already a member
$db = supabaseService();
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
}
// Handle file upload
$documentUrl = null;
if (isset($_FILES['proof_document'])) {
if ($_FILES['proof_document']['error'] !== UPLOAD_ERR_OK) {
$errors = [1 => 'File exceeds server limit', 2 => 'File exceeds form limit', 3 => 'Partial upload', 4 => 'No file sent', 6 => 'No tmp dir', 7 => 'Write failed'];
jsonError($errors[$_FILES['proof_document']['error']] ?? 'Upload error code ' . $_FILES['proof_document']['error']);
}
$file = $_FILES['proof_document'];
if ($file['size'] > 5 * 1024 * 1024) {
jsonError('File too large (max 5MB)');
}
$mime = mime_content_type($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowed)) {
jsonError('Invalid file type: ' . $mime . '. Allowed: JPEG, PNG, WebP');
}
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg'
};
$filename = $userId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$storagePath = 'org-proofs/' . $userId . '/' . $filename;
$bucket = 'assets';
$storageUrl = SUPABASE_STORAGE . '/object/' . $bucket . '/' . $storagePath;
$fileContents = file_get_contents($file['tmp_name']);
if ($fileContents === false) {
jsonError('Failed to read uploaded file');
}
$ch = curl_init($storageUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'apikey: ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime,
'x-upsert: true'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($curlError) {
jsonError('Storage connection failed: ' . $curlError, 500);
}
if ($httpCode >= 400) {
$decoded = json_decode($response, true);
jsonError('Storage error (' . $httpCode . '): ' . ($decoded['message'] ?? $decoded['error'] ?? $response), 500);
}
$documentUrl = SUPABASE_STORAGE . '/object/public/' . $bucket . '/' . $storagePath . '?apikey=' . SUPABASE_ANON_KEY;
}
// Insert application
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
}
$result = $db->insert('org_membership_applications', $appData);
if (isset($result['error'])) {
jsonError('Failed to submit application: ' . $result['error']);
}
$r = orgApply($userId, $orgId, (string)$documentType, (string)($_POST['notes'] ?? ''), $_FILES['proof_document'] ?? null, redeemIpHash());
if (empty($r['ok']) || ($r['status'] ?? '') === 'already_member') orgLegacyFail($r);
jsonResponse([
'success' => true,
'application_id' => $result[0]['id'] ?? null,
'document_url' => $documentUrl
'status' => $r['status'],
'application_id' => $r['application']['id'] ?? null,
'document_url' => null,
]);
This diff is collapsed.
......@@ -72,7 +72,7 @@ if ($method === 'GET') {
}
$profile = $profiles[0];
$ratings = $db->get('player_game_ratings', ['player_id' => 'eq.' . $targetId, 'select' => 'game_key,time_control,rating,games_played,wins,losses,draws']);
$ratings = $db->get('player_game_ratings', ['player_id' => 'eq.' . $targetId, 'select' => 'game_key,mode,rating,games_played,wins,losses,draws']);
$profile['ratings'] = is_array($ratings) && !isset($ratings['error']) ? $ratings : [];
......@@ -210,84 +210,56 @@ if ($method === 'POST') {
jsonResponse($profile ?? ['id' => $userId, 'display_name' => $name]);
}
if ($action === 'get-orgs') {
$db = supabaseService();
$orgs = $db->get('organizations', ['select' => 'id,name,slug,logo_url,country_code', 'order' => 'name.asc']);
if (isset($orgs['error'])) jsonError($orgs['error']);
jsonResponse($orgs);
}
if ($action === 'my-memberships') {
$db = supabaseService();
$memberships = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'select' => 'id,organization_id,role,status,joined_at,organizations(id,name,slug,logo_url)'
]);
if (isset($memberships['error'])) jsonError($memberships['error']);
jsonResponse(is_array($memberships) ? $memberships : []);
}
if ($action === 'my-applications') {
$db = supabaseService();
$apps = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'select' => 'id,org_id,status,document_type,notes,rejection_reason,created_at,organizations(id,name,slug,logo_url)',
'order' => 'created_at.desc',
'limit' => 50
]);
if (isset($apps['error'])) jsonError($apps['error']);
jsonResponse(is_array($apps) ? $apps : []);
}
if ($action === 'apply-org') {
$orgId = $input['org_id'] ?? null;
$documentType = $input['document_type'] ?? null;
$notes = $input['notes'] ?? '';
$documentUrl = $input['document_url'] ?? null;
if (!$orgId || !$documentType) {
jsonError('org_id and document_type are required');
}
// The four org actions below are proxies onto the El3ab org model
// (el3ab_organizations, org_members) for one release, in their old shapes.
// New code calls api/orgs.php (docs/redeem-api.md 9.2).
if (in_array($action, ['get-orgs', 'my-memberships', 'my-applications', 'apply-org'], true)) {
require_once __DIR__ . '/../includes/orgs.php';
$sdb = supabaseService();
$legacyOrg = fn($o) => $o ? ['id' => $o['id'], 'name' => $o['name'], 'name_ar' => $o['name_ar'] ?? null,
'slug' => $o['slug'] ?? null, 'logo_url' => $o['logo_url'] ?? null] : null;
// Check if already a member
$db = supabase($token);
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
if ($action === 'get-orgs') {
$orgs = $sdb->get('el3ab_organizations', [
'is_active' => 'eq.true', 'order' => 'name.asc', 'limit' => 200,
'select' => 'id,name,name_ar,slug,logo_url,country_code,tag,hue,join_policy,member_count',
]);
if (isset($orgs['error'])) jsonError($orgs['error']);
jsonResponse(array_map(function ($o) {
$o['country_code'] = $o['country_code'] !== null ? trim($o['country_code']) : null;
return $o;
}, $orgs));
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
if ($action === 'my-memberships') {
jsonResponse(array_map(fn($m) => [
'id' => $m['id'], 'organization_id' => $m['org_id'], 'org_id' => $m['org_id'], 'role' => $m['role'],
'status' => $m['status'], 'joined_at' => $m['joined_at'],
'organizations' => $legacyOrg($m['el3ab_organizations']),
], orgMyMemberships($sdb, $userId)));
}
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
if ($action === 'my-applications') {
jsonResponse(array_map(fn($a) => [
'id' => $a['id'], 'org_id' => $a['org_id'], 'status' => $a['status'], 'document_type' => $a['document_type'],
'notes' => $a['notes'], 'rejection_reason' => $a['rejection_reason'], 'review_note' => $a['review_note'],
'created_at' => $a['created_at'], 'organizations' => $legacyOrg($a['el3ab_organizations'] ?? null),
], orgMyApplications($sdb, $userId)));
}
$dbService = supabaseService();
$result = $dbService->insert('org_membership_applications', $appData);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse($result[0] ?? ['success' => true]);
// apply-org: JSON only. The client's own document_url is ignored: a
// proof is uploaded through orgs.php apply (multipart) into the
// private bucket, never linked from anywhere.
$orgId = $input['org_id'] ?? null;
$documentType = $input['document_type'] ?? null;
if (!isUuid($orgId) || !$documentType) jsonError('org_id and document_type are required');
$r = orgApply($userId, $orgId, (string)$documentType, (string)($input['notes'] ?? ''), null, redeemIpHash());
if (empty($r['ok']) || ($r['status'] ?? '') === 'already_member') orgLegacyFail($r);
$app = $r['application'] ?? null;
jsonResponse($app ? [
'id' => $app['id'], 'org_id' => $app['org_id'], 'player_id' => $userId, 'status' => $app['status'],
'document_type' => $app['document_type'], 'notes' => $input['notes'] ?? '', 'created_at' => $app['created_at'],
] : ['success' => true, 'status' => $r['status']]);
}
jsonError('Invalid action');
......
<?php
/**
* Redeem codes for players: one box for org invites, tournament entries,
* group codes and promo codes.
*
* GET|POST ?action=preview&code=&source= account optional (rate limited by IP)
* POST {action:'apply', code, source, device_id} a permanent account
* GET ?action=history[&limit=&offset=] my redemptions
* GET ?action=intents a guest's held codes
*
* HTTP contract: docs/redeem-api.md section 1. Database: docs/redeem-contract.md.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/redeem.php';
$method = $_SERVER['REQUEST_METHOD'];
$input = $method === 'POST' ? getInput() : [];
$action = (string)($method === 'POST' ? ($input['action'] ?? '') : ($_GET['action'] ?? ''));
$param = fn(string $k, $default = null) => $method === 'POST' ? ($input[$k] ?? $default) : ($_GET[$k] ?? $default);
if ($action === 'preview' && ($method === 'GET' || $method === 'POST')) {
$caller = redeemOptionalCaller();
$source = redeemSource($param('source'));
if ($source === null) el3abFailKey('invalid_request', 'redeem', ['detail' => 'source']);
$r = redeemPreview($caller['id'] ?? null, $param('code', ''), $source, redeemIpHash(), redeemDeviceId($param('device_id')));
if (empty($r['ok'])) el3abFail($r, 'redeem');
jsonResponse($r);
}
if ($action === 'apply' && $method === 'POST') {
$caller = redeemOptionalCaller();
// 403, not 401: a 401 makes the client refresh and then sign a guest out.
if (!$caller) el3abFailKey('account_required', 'redeem', ['intent_saved' => false]);
$source = redeemSource($input['source'] ?? null);
if ($source === null) el3abFailKey('invalid_request', 'redeem', ['detail' => 'source']);
$r = redeemApply($caller['id'], $input['code'] ?? '', $source, redeemIpHash(), redeemDeviceId($input['device_id'] ?? null));
if (empty($r['ok'])) el3abFail($r, 'redeem');
jsonResponse($r, redeemHttpFor($r));
}
if ($action === 'history' && $method === 'GET') {
$token = requireAuth();
$uid = getUserId($token);
$limit = max(1, min((int)($_GET['limit'] ?? 50), 100));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$sdb = supabaseService();
$rows = $sdb->get('redeem_redemptions', [
'player_id' => 'eq.' . $uid,
'select' => 'id,status,source,created_at,results,reversed_at,'
. 'redeem_codes(code,code_display,label,kind,scope,org_id,el3ab_organizations(id,name,name_ar,tag,logo_url,member_count,max_members,is_verified,hue,join_policy))',
'order' => 'created_at.desc', 'limit' => $limit, 'offset' => $offset,
]);
if (!is_array($rows) || isset($rows['error'])) el3abFailKey('server_error');
$out = array_map(function ($r) {
$c = $r['redeem_codes'] ?? [];
return [
'id' => $r['id'], 'code_display' => $c['code_display'] ?? null, 'label' => $c['label'] ?? null,
'kind' => $c['kind'] ?? null, 'scope' => $c['scope'] ?? null, 'link' => redeemLink($c['code'] ?? null),
'status' => $r['status'], 'source' => $r['source'], 'created_at' => $r['created_at'],
'results' => $r['results'] ?? [], 'org' => $c['el3ab_organizations'] ?? null, 'reversed_at' => $r['reversed_at'],
];
}, $rows);
jsonResponse(['ok' => true, 'redemptions' => $out, 'limit' => $limit, 'offset' => $offset]);
}
if ($action === 'intents' && $method === 'GET') {
$token = requireAuth();
jsonResponse(['ok' => true, 'intents' => redeemIntents(getUserId($token))]);
}
el3abFailKey('invalid_request', 'redeem', ['detail' => 'action'], 400);
This diff is collapsed.
......@@ -15,7 +15,14 @@ $db = supabase($token);
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET' || ($_SERVER['REQUEST_METHOD'] === 'POST' && (getInput()['action'] ?? '') === 'list')) {
$items = $db->get('cosmetics', ['select' => 'id,name,name_ar,type,rarity,price_coins,price_gems,description', 'order' => 'price_coins.asc']);
// Only what is on sale right now: is_purchasable, and not past
// available_until (limited editions end). Both are set in the manager.
$items = supabaseService()->get('cosmetics', [
'select' => 'id,name,name_ar,type,rarity,price_coins,price_gems,description,is_limited_edition,available_until',
'is_purchasable' => 'is.true',
'or' => '(available_until.is.null,available_until.gt.' . gmdate('Y-m-d\TH:i:s\Z') . ')',
'order' => 'price_coins.asc',
]);
jsonResponse(['items' => is_array($items) && !isset($items['error']) ? $items : []]);
}
......@@ -32,6 +39,11 @@ if ($action === 'purchase') {
$items = $sdb->get('cosmetics', ['id' => 'eq.' . $cosmeticId, 'limit' => 1]);
$item = is_array($items) && !empty($items) && !isset($items['error']) ? $items[0] : null;
if (!$item) jsonError('Item not found', 404);
// The same rule as the list: not for sale, or its sale has ended.
if (empty($item['is_purchasable'])) jsonResponse(['error' => 'This item is not for sale', 'code' => 'not_for_sale'], 409);
if (!empty($item['available_until']) && strtotime($item['available_until']) <= time()) {
jsonResponse(['error' => 'This item is no longer available', 'code' => 'sale_ended'], 409);
}
$owned = $sdb->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id']);
if (!empty($owned) && !isset($owned['error'])) jsonError('Already owned');
......
......@@ -73,6 +73,12 @@ function swissApi(string $method, string $path, ?array $body = null): array {
return $data;
}
/** Where the manager app answers publicly (its /live/{slug} pages). */
function managerPublicUrl(): string {
$url = getenv('MANAGER_PUBLIC_URL');
return rtrim($url !== false && $url !== '' ? $url : 'https://el3ab-management.caprover.al-arcade.com', '/');
}
function requireTournament($db, string $id): array {
$t = tournamentLoad($db, $id);
if (!$t) jsonError('Tournament not found', 404);
......@@ -85,6 +91,8 @@ function getTournament(): void {
$db = supabaseService();
$t = requireTournament($db, $id);
// A draft is the organiser's work in progress; players never see one.
if (($t['status'] ?? '') === TOURNAMENT_STATUS_DRAFT) jsonError('Tournament not found', 404);
$players = tournamentPlayers($db, $id, $t['time_control'] ?? 'rapid_10_0');
$t['player_count'] = count($players);
......@@ -99,6 +107,21 @@ function getTournament(): void {
'completed' => count(array_filter($r['pairings'], fn($p) => ($p['result'] ?? null) !== null)),
], $rounds);
// What the organiser posted on the manager's live page, pinned first.
$notes = $db->get('tournament_announcements', [
'tournament_id' => 'eq.' . $id,
'select' => 'id,title,content,type,is_pinned,created_at',
'order' => 'is_pinned.desc,created_at.desc',
'limit' => 20,
]);
$t['announcements'] = (is_array($notes) && !isset($notes['error'])) ? $notes : [];
// The public live page lives on the manager (/live/{slug}) and only
// answers while live_enabled is on.
$t['live_url'] = (!empty($t['live_enabled']) && !empty($t['slug']))
? managerPublicUrl() . '/live/' . rawurlencode($t['slug'])
: null;
jsonResponse($t);
}
......@@ -225,7 +248,9 @@ function getBracket(): void {
jsonResponse(['bracket' => [], 'format' => $t['format'] ?? null, 'supported' => false]);
}
$rows = $db->get('bracket_matches', ['tournament_id' => 'eq.' . $tournamentId, 'select' => '*', 'order' => 'round.asc']);
// The column is round_number; ordering by `round` answered 42703, which is
// why the bracket was always empty.
$rows = $db->get('bracket_matches', ['tournament_id' => 'eq.' . $tournamentId, 'select' => '*', 'order' => 'round_number.asc,match_number.asc']);
jsonResponse([
'bracket' => (is_array($rows) && !isset($rows['error'])) ? $rows : [],
'format' => $t['format'],
......
<?php
/**
* Theme values as one flat map, with per-game and per-organisation overrides.
*
* Nothing in the app calls this (public/js/core/theme.js reads
* api/branding.php); it is kept for outside callers. It used to select
* columns these tables do not have (platform_theme.key, key/value on both
* override tables), so every answer was an empty theme. It now reads:
*
* platform_theme id => value
* game_theme_overrides one row per game, one column per value
* org_theme_overrides theme_token_id => override_value, approved rows only
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
......@@ -8,32 +21,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$gameKey = $_GET['game_key'] ?? null;
$orgId = $_GET['org_id'] ?? null;
$gameKey = isset($_GET['game_key']) ? preg_replace('/[^a-z0-9_]/', '', (string)$_GET['game_key']) : '';
$orgId = isset($_GET['org_id']) && preg_match('/^[0-9a-f-]{36}$/i', (string)$_GET['org_id']) ? (string)$_GET['org_id'] : '';
$theme = [];
$platform = $db->get('platform_theme', ['select' => 'key,value', 'limit' => 50]);
$platform = $db->get('platform_theme', ['select' => 'id,value']);
if (is_array($platform) && !isset($platform['error'])) {
foreach ($platform as $row) {
$theme[$row['key']] = $row['value'];
if ($row['value'] !== null && $row['value'] !== '') $theme[$row['id']] = $row['value'];
}
}
if ($gameKey) {
$gameTheme = $db->get('game_theme_overrides', ['game_key' => 'eq.' . $gameKey, 'select' => 'key,value']);
if (is_array($gameTheme) && !isset($gameTheme['error'])) {
foreach ($gameTheme as $row) {
$theme[$row['key']] = $row['value'];
if ($gameKey !== '') {
$rows = $db->get('game_theme_overrides', ['game_key' => 'eq.' . $gameKey, 'select' => '*', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && !empty($rows)) {
foreach ($rows[0] as $col => $value) {
if (in_array($col, ['game_key', 'updated_by', 'updated_at'], true)) continue;
if ($value === null || $value === '') continue;
$theme[$col] = $value;
}
}
}
if ($orgId) {
$orgTheme = $db->get('org_theme_overrides', ['org_id' => 'eq.' . $orgId, 'select' => 'key,value']);
if (is_array($orgTheme) && !isset($orgTheme['error'])) {
foreach ($orgTheme as $row) {
$theme[$row['key']] = $row['value'];
if ($orgId !== '') {
$rows = $db->get('org_theme_overrides', [
'org_id' => 'eq.' . $orgId,
'is_approved' => 'is.true',
'select' => 'theme_token_id,override_value',
]);
if (is_array($rows) && !isset($rows['error'])) {
foreach ($rows as $row) {
if (!empty($row['theme_token_id']) && $row['override_value'] !== null) {
$theme[$row['theme_token_id']] = $row['override_value'];
}
}
}
}
......
......@@ -323,7 +323,9 @@ function adminSetResult($db, ?string $userId, array $input, string $actor): void
$t = requireTournamentAdmin($db, $userId, $id);
requireNative($t);
if (in_array($t['status'], [TOURNAMENT_STATUS_CANCELLED, TOURNAMENT_STATUS_DRAFT, TOURNAMENT_STATUS_REGISTRATION], true)) {
// Only while the event runs: once it is completed the final placings are
// written, and a later change would leave them disagreeing with the table.
if (!in_array($t['status'], [TOURNAMENT_STATUS_IN_PROGRESS, TOURNAMENT_STATUS_PAUSED], true)) {
jsonError('Tournament is ' . $t['status'], 409);
}
......@@ -359,6 +361,7 @@ function adminSetResult($db, ?string $userId, array $input, string $actor): void
$p['set_by'] = $actor;
$p['set_at'] = gmdate('c');
if ($previous !== null) $p['previous_result'] = $previous;
else unset($p['previous_result']);
break;
}
unset($p);
......
This diff is collapsed.
......@@ -73,7 +73,7 @@ for emphasis). Wrap numbers inside Arabic text in `<span class="num">`.
bot puzzle dice chess domino backgammon ludo flame lightning stopwatch hourglass
chat handshake shop bag star lock home search ticket calendar check cross plus
flag exit speaker mute edit trash scroll shield party history refresh live team
king sparkles heart spade diamond club hand up dicefire).
king sparkles heart spade diamond club hand up dicefire key megaphone whistle castle).
- `crown(size)`: the El3ab logo.
- `icon(name, size)`: thin line glyphs for small marks in text (chevrons, back,
x, check). Back arrows, chevrons and send mirror in RTL automatically.
......
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
<?php
/**
* Arabic for GD: shape a line into Presentation Forms-B and put it in visual
* (left-to-right drawing) order, because GD/FreeType draws code points one by
* one and never shapes. Draw the result with includes/fonts/el3ab-card-800.ttf
* (tools/build-card-font.py maps every presentation form in it).
*
* imagettftext($im, 48, 0, $x, $y, $ink, EL3AB_CARD_FONT, arabicVisual('نادي القاهرة'));
*
* Covers what org and player names use: the 28 letters and their hamza and
* madda variants, teh marbuta, alef maksura, the lam-alef ligatures, tatweel
* and harakat (dropped), mixed with Latin words and digits (kept in reading
* order inside an RTL line). It is a small bidi, not the full algorithm.
*/
const EL3AB_CARD_FONT = __DIR__ . '/fonts/el3ab-card-800.ttf';
/** base => [isolated, final, initial, medial] (0 = no such form). Generated from Unicode data. */
const ARABIC_FORMS = [
0x0621 => [0xFE80, 0x0000, 0x0000, 0x0000], // hamza
0x0622 => [0xFE81, 0xFE82, 0x0000, 0x0000], // alef with madda above
0x0623 => [0xFE83, 0xFE84, 0x0000, 0x0000], // alef with hamza above
0x0624 => [0xFE85, 0xFE86, 0x0000, 0x0000], // waw with hamza above
0x0625 => [0xFE87, 0xFE88, 0x0000, 0x0000], // alef with hamza below
0x0626 => [0xFE89, 0xFE8A, 0xFE8B, 0xFE8C], // yeh with hamza above
0x0627 => [0xFE8D, 0xFE8E, 0x0000, 0x0000], // alef
0x0628 => [0xFE8F, 0xFE90, 0xFE91, 0xFE92], // beh
0x0629 => [0xFE93, 0xFE94, 0x0000, 0x0000], // teh marbuta
0x062A => [0xFE95, 0xFE96, 0xFE97, 0xFE98], // teh
0x062B => [0xFE99, 0xFE9A, 0xFE9B, 0xFE9C], // theh
0x062C => [0xFE9D, 0xFE9E, 0xFE9F, 0xFEA0], // jeem
0x062D => [0xFEA1, 0xFEA2, 0xFEA3, 0xFEA4], // hah
0x062E => [0xFEA5, 0xFEA6, 0xFEA7, 0xFEA8], // khah
0x062F => [0xFEA9, 0xFEAA, 0x0000, 0x0000], // dal
0x0630 => [0xFEAB, 0xFEAC, 0x0000, 0x0000], // thal
0x0631 => [0xFEAD, 0xFEAE, 0x0000, 0x0000], // reh
0x0632 => [0xFEAF, 0xFEB0, 0x0000, 0x0000], // zain
0x0633 => [0xFEB1, 0xFEB2, 0xFEB3, 0xFEB4], // seen
0x0634 => [0xFEB5, 0xFEB6, 0xFEB7, 0xFEB8], // sheen
0x0635 => [0xFEB9, 0xFEBA, 0xFEBB, 0xFEBC], // sad
0x0636 => [0xFEBD, 0xFEBE, 0xFEBF, 0xFEC0], // dad
0x0637 => [0xFEC1, 0xFEC2, 0xFEC3, 0xFEC4], // tah
0x0638 => [0xFEC5, 0xFEC6, 0xFEC7, 0xFEC8], // zah
0x0639 => [0xFEC9, 0xFECA, 0xFECB, 0xFECC], // ain
0x063A => [0xFECD, 0xFECE, 0xFECF, 0xFED0], // ghain
0x0641 => [0xFED1, 0xFED2, 0xFED3, 0xFED4], // feh
0x0642 => [0xFED5, 0xFED6, 0xFED7, 0xFED8], // qaf
0x0643 => [0xFED9, 0xFEDA, 0xFEDB, 0xFEDC], // kaf
0x0644 => [0xFEDD, 0xFEDE, 0xFEDF, 0xFEE0], // lam
0x0645 => [0xFEE1, 0xFEE2, 0xFEE3, 0xFEE4], // meem
0x0646 => [0xFEE5, 0xFEE6, 0xFEE7, 0xFEE8], // noon
0x0647 => [0xFEE9, 0xFEEA, 0xFEEB, 0xFEEC], // heh
0x0648 => [0xFEED, 0xFEEE, 0x0000, 0x0000], // waw
0x0649 => [0xFEEF, 0xFEF0, 0x0000, 0x0000], // alef maksura
0x064A => [0xFEF1, 0xFEF2, 0xFEF3, 0xFEF4], // yeh
];
/** lam + alef variant => [isolated ligature, final ligature]. */
const ARABIC_LAM_ALEF = [
0x0622 => [0xFEF5, 0xFEF6], // lam alef madda
0x0623 => [0xFEF7, 0xFEF8], // lam alef hamza above
0x0625 => [0xFEF9, 0xFEFA], // lam alef hamza below
0x0627 => [0xFEFB, 0xFEFC], // lam alef
];
function arabicIsLetter(int $cp): bool { return isset(ARABIC_FORMS[$cp]); }
/** Joins to the letter after it (has initial and medial forms). */
function arabicJoinsNext(int $cp): bool { return isset(ARABIC_FORMS[$cp]) && ARABIC_FORMS[$cp][2] !== 0; }
/** Joins to the letter before it (has a final form). */
function arabicJoinsPrev(int $cp): bool { return isset(ARABIC_FORMS[$cp]) && ARABIC_FORMS[$cp][1] !== 0; }
/** Shape (logical order in, logical order out): every letter in its contextual form. */
function arabicShape(string $text): string {
$cps = array_values(array_filter(array_map('mb_ord', mb_str_split($text)), function ($c) {
// Harakat and the tatweel are dropped: the card is set in one weight, unvowelled.
return !(($c >= 0x064B && $c <= 0x0652) || $c === 0x0670 || $c === 0x0640);
}));
$out = [];
$n = count($cps);
for ($i = 0; $i < $n; $i++) {
$c = $cps[$i];
if (!arabicIsLetter($c)) { $out[] = $c; continue; }
$prev = $i > 0 ? $cps[$i - 1] : 0;
$joinPrev = $prev && arabicJoinsNext($prev) && arabicJoinsPrev($c);
// Lam followed by an alef: one ligature glyph.
if ($c === 0x0644 && $i + 1 < $n && isset(ARABIC_LAM_ALEF[$cps[$i + 1]])) {
$out[] = ARABIC_LAM_ALEF[$cps[$i + 1]][$joinPrev ? 1 : 0];
$i++;
continue;
}
$next = $i + 1 < $n ? $cps[$i + 1] : 0;
$joinNext = $next && arabicJoinsNext($c) && arabicJoinsPrev($next);
[$iso, $fin, $ini, $med] = ARABIC_FORMS[$c];
$form = $joinPrev && $joinNext ? $med : ($joinPrev ? $fin : ($joinNext ? $ini : $iso));
$out[] = $form ?: $c;
}
return implode('', array_map('mb_chr', $out));
}
/** True when the text has any Arabic letter. */
function arabicHas(string $text): bool {
return preg_match('/[\x{0600}-\x{06FF}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text) === 1;
}
/**
* One line, shaped and in visual order for left-to-right drawing. A line
* with no Arabic is returned as it is. In an Arabic line, runs of Latin
* letters and digits keep their own order; everything else reads right to left.
*/
function arabicVisual(string $text): string {
if (!arabicHas($text)) return $text;
$shaped = arabicShape($text);
// Split into LTR runs (Latin letters, digits, and the joiners inside them) and the rest.
preg_match_all('/[A-Za-z0-9][A-Za-z0-9.,:\/\-+%#@&_]*[A-Za-z0-9%]|[A-Za-z0-9]|[^A-Za-z0-9]/u', $shaped, $m);
$runs = $m[0];
$parts = [];
foreach (array_reverse($runs) as $run) {
if (preg_match('/^[A-Za-z0-9]/', $run)) { $parts[] = $run; continue; }
// A right-to-left character: mirror paired brackets so they face the right way.
$mirror = ['(' => ')', ')' => '(', '[' => ']', ']' => '[', '{' => '}', '}' => '{', '<' => '>', '>' => '<'];
$parts[] = $mirror[$run] ?? $run;
}
return implode('', $parts);
}
......@@ -169,7 +169,7 @@ function assertNotBanned(?string $userId): void {
if ($cached === null) {
require_once __DIR__ . '/supabase.php';
$sdb = supabaseService();
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at', 'limit' => 1]);
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at,ban_reason', 'limit' => 1]);
$cached = (is_array($profiles) && !empty($profiles) && !isset($profiles['error'])) ? $profiles[0] : [];
authCacheSet($cacheKey, $cached, AUTH_CACHE_TTL);
}
......@@ -179,7 +179,13 @@ function assertNotBanned(?string $userId): void {
if (!$banExpires || strtotime($banExpires) > time()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Account banned', 'ban_expires_at' => $banExpires]);
// The app shows the ban screen from this body (net.js: auth:banned).
echo json_encode([
'error' => 'Account banned',
'code' => 'banned',
'ban_expires_at' => $banExpires,
'ban_reason' => $cached['ban_reason'] ?? null,
], JSON_UNESCAPED_UNICODE);
exit;
}
}
......
......@@ -231,6 +231,25 @@ function chessWinnerId(?string $winner, ?string $whiteId, ?string $blackId): ?st
return null;
}
/**
* Who complete_match() pays for a finished chess game, and as what:
* [{"id": white, "result": "win"|"loss"|"draw"}, {"id": black, ...}], so the
* loser is paid chess_loss_* and a draw pays both chess_draw_* (reward_config).
* The function skips empty seats, bots and ids with no profile. An aborted
* game (nobody played) pays nobody.
*/
function chessPayoutPlayers(array $match, array $derived): array {
if (($derived['result'] ?? '') === 'aborted') return [];
$winner = $derived['winner'] ?? null; // 'white' | 'black' | null (a draw)
if ($winner === null && ($derived['result'] ?? '') !== 'draw') return [];
$out = [];
foreach (['white' => $match['white_player_id'] ?? null, 'black' => $match['black_player_id'] ?? null] as $side => $id) {
if (!$id) continue;
$out[] = ['id' => $id, 'result' => $winner === null ? 'draw' : ($winner === $side ? 'win' : 'loss')];
}
return $out;
}
/** Points for the Swiss table: [whitePoints, blackPoints]. */
function chessResultPoints(string $result): array {
if ($result === 'white_wins') return [1.0, 0.0];
......
Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
<?php
/**
* Values staff edit in the manager and the game reads.
*
* reward_config is the table complete_match() already reads coins and XP from
* (key => text value). The daily reward ladder and the Elo K-factor used to be
* hard-coded in PHP; they now come from here too, with the old numbers as the
* defaults (migrations/2026-10-26-game-config-keys.sql adds the rows).
*
* Every request that needs one value would otherwise cost a query, so the whole
* table is read once and kept for CONFIG_CACHE_TTL seconds (APCu when present,
* a temp file otherwise). A change in the manager reaches players within that.
*/
require_once __DIR__ . '/supabase.php';
const CONFIG_CACHE_TTL = 60;
/** The daily reward ladder the game always paid, used for any day with no valid row. */
const DAILY_REWARD_DEFAULTS = [50, 75, 100, 125, 150, 200, 300];
/** Elo K defaults: FIDE-style, smaller as a rating rises. */
const ELO_K_DEFAULTS = [
'elo_k_factor_default' => 32,
'elo_k_high_rating' => 2000,
'elo_k_factor_high' => 24,
'elo_k_master_rating' => 2400,
'elo_k_factor_master' => 16,
];
function gameConfigCacheGet(string $key) {
if (function_exists('apcu_fetch')) {
$ok = false;
$v = apcu_fetch('el3ab_cfg_' . $key, $ok);
return $ok ? $v : null;
}
$f = sys_get_temp_dir() . '/el3ab_cfg_' . hash('sha256', $key);
if (!is_file($f) || filemtime($f) < time() - CONFIG_CACHE_TTL) return null;
$v = json_decode((string)@file_get_contents($f), true);
return is_array($v) ? $v : null;
}
function gameConfigCacheSet(string $key, array $value): void {
if (function_exists('apcu_store')) { apcu_store('el3ab_cfg_' . $key, $value, CONFIG_CACHE_TTL); return; }
@file_put_contents(sys_get_temp_dir() . '/el3ab_cfg_' . hash('sha256', $key), json_encode($value), LOCK_EX);
}
/** Every reward_config row as key => value (text). Empty when the table cannot be read. */
function rewardConfigAll(): array {
static $memo = null;
if ($memo !== null) return $memo;
$cached = gameConfigCacheGet('reward_config');
if ($cached !== null) return $memo = $cached;
$rows = supabaseService()->get('reward_config', ['select' => 'key,value']);
if (!is_array($rows) || isset($rows['error'])) return $memo = []; // not cached: try again next request
$map = [];
foreach ($rows as $r) {
if (isset($r['key'])) $map[$r['key']] = $r['value'];
}
gameConfigCacheSet('reward_config', $map);
return $memo = $map;
}
/**
* A whole-number reward_config value, else $default.
* Anything that is not a plain integer (empty, "abc", "1.5", a quoted "\"20\"")
* falls back rather than being half-parsed into a surprise.
*/
function rewardConfigInt(string $key, int $default, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int {
$all = rewardConfigAll();
if (!array_key_exists($key, $all)) return $default;
$raw = trim((string)$all[$key]);
if (!preg_match('/^-?\d+$/', $raw)) return $default;
return max($min, min($max, (int)$raw));
}
/** Coins for each day of the 7-day streak, day 1 first. */
function dailyRewardLadder(): array {
$ladder = [];
foreach (DAILY_REWARD_DEFAULTS as $i => $default) {
$ladder[] = rewardConfigInt('daily_reward_day_' . ($i + 1), $default, 0, 1000000);
}
return $ladder;
}
/** The chess Elo K-factor for a player at this rating. */
function eloKFactorFromConfig(int $rating): int {
$k = [];
foreach (ELO_K_DEFAULTS as $key => $default) {
$k[$key] = rewardConfigInt($key, $default, 0, 10000);
}
// A K of 0 freezes ratings; staff who set one meant something else.
foreach (['elo_k_factor_default', 'elo_k_factor_high', 'elo_k_factor_master'] as $key) {
if ($k[$key] < 1) $k[$key] = ELO_K_DEFAULTS[$key];
}
if ($rating > $k['elo_k_master_rating']) return $k['elo_k_factor_master'];
if ($rating > $k['elo_k_high_rating']) return $k['elo_k_factor_high'];
return $k['elo_k_factor_default'];
}
<?php
/**
* Link previews for invite links (design 2.5, feature I4).
*
* index.php calls el3abLinkPreview() for every page. For /r/CODE (and the
* /?org=CODE, /?code=CODE aliases) it looks the code up once, read-only, with
* the service key: a redeem_code dry run with source 'link' or 'qr', which
* counts the link open and logs an unknown code against the caller's IP hash
* (docs/redeem-contract.md 9), so /r/ is no easier an oracle than the API.
*
* a live code og:title "انضم إلى {org} على العب", the welcome message or the
* org's description, the org's share card (api/share-card.php)
* anything else the generic El3ab tags (an unknown, expired or revoked code
* looks exactly like the home page: nothing is given away)
*
* A code-shaped link also sets window.__EL3AB_LINK__ = {kind:'redeem', code,
* source}, which core/deeplink.js picks up before the app boots.
*
* Returns ['title' => text, 'meta' => html, 'script' => html]. Never throws, and
* never makes the page wait more than about 3 seconds.
*/
function el3abLinkEsc(?string $s): string {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function el3abLinkBase(): string {
$proto = strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
$https = $proto === 'https' || (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$host = preg_replace('/[^A-Za-z0-9.:-]/', '', (string)($_SERVER['HTTP_HOST'] ?? ''));
if ($host === '') return rtrim(getenv('PLAYER_APP_URL') ?: 'https://el3ab-player.caprover.al-arcade.com', '/');
return ($https ? 'https' : 'http') . '://' . $host;
}
function el3abLinkTags(string $title, string $desc, string $image, string $url): string {
$t = el3abLinkEsc($title); $d = el3abLinkEsc($desc); $i = el3abLinkEsc($image); $u = el3abLinkEsc($url);
return implode("\n ", [
'<meta name="description" content="' . $d . '">',
'<meta property="og:type" content="website">',
'<meta property="og:site_name" content="العب">',
'<meta property="og:locale" content="ar_EG">',
'<meta property="og:title" content="' . $t . '">',
'<meta property="og:description" content="' . $d . '">',
'<meta property="og:url" content="' . $u . '">',
'<meta property="og:image" content="' . $i . '">',
'<meta property="og:image:type" content="image/png">',
'<meta property="og:image:width" content="1200">',
'<meta property="og:image:height" content="630">',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:title" content="' . $t . '">',
'<meta name="twitter:description" content="' . $d . '">',
'<meta name="twitter:image" content="' . $i . '">',
]);
}
/** POST an RPC with the service key and a short timeout (the page is waiting). */
function el3abLinkRpc(string $fn, array $args, int $timeout = 3): ?array {
$ch = curl_init(SUPABASE_REST . '/rpc/' . $fn);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($args),
CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_HTTPHEADER => ['apikey: ' . SUPABASE_SERVICE_KEY, 'Authorization: Bearer ' . SUPABASE_SERVICE_KEY, 'Content-Type: application/json'],
]);
$raw = curl_exec($ch);
$r = $raw === false ? null : json_decode((string)$raw, true);
return is_array($r) ? $r : null;
}
function el3abLinkName(?array $x): string {
if (!$x) return '';
return trim((string)(($x['name_ar'] ?? '') ?: ($x['name'] ?? '')));
}
function el3abLinkPreview(): array {
$base = el3abLinkBase();
$generic = [
'title' => 'EL3AB',
'meta' => el3abLinkTags(
'العب: شطرنج ولودو ودومينو وطاولة',
'العب مع صحابك أونلاين أو قدام الكمبيوتر، واكسب عملات وادخل بطولات',
$base . '/api/share-card.php',
$base . '/'
),
'script' => '',
];
try {
$path = parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
$raw = null;
if (preg_match('#^/r/([^/?\#]{4,40})$#', $path, $m)) $raw = rawurldecode($m[1]);
elseif ($path === '/' && (isset($_GET['org']) || isset($_GET['code']))) $raw = (string)($_GET['org'] ?? $_GET['code']);
if ($raw === null) return $generic;
require_once __DIR__ . '/redeem.php';
$code = redeemCanonical($raw);
if ($code === null) return $generic;
$source = strtolower((string)($_GET['s'] ?? $_GET['src'] ?? '')) === 'qr' ? 'qr' : 'link';
$generic['script'] = '<script>window.__EL3AB_LINK__ = ' . json_encode(
['kind' => 'redeem', 'code' => $code, 'source' => $source],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';</script>';
$r = el3abLinkRpc('redeem_code', [
'p_player' => null, 'p_code' => $code, 'p_source' => $source,
'p_ip_hash' => redeemIpHash(), 'p_device' => null, 'p_dry_run' => true,
]);
if (!$r || empty($r['ok'])) return $generic;
$grants = is_array($r['grants'] ?? null) ? $r['grants'] : [];
$org = is_array($r['org'] ?? null) ? $r['org'] : null;
if (!$org) foreach ($grants as $g) if (($g['type'] ?? '') === 'org_join' && is_array($g['org'] ?? null)) { $org = $g['org']; break; }
$welcome = trim((string)(($r['welcome_message_ar'] ?? '') ?: ($r['welcome_message'] ?? '')));
$image = $base . '/api/share-card.php';
$link = $base . '/r/' . rawurlencode($code);
if ($org && !empty($org['id'])) {
$name = el3abLinkName($org);
$title = 'انضم إلى ' . $name . ' على العب';
$inviter = is_array($r['inviter'] ?? null) ? trim((string)($r['inviter']['name'] ?? '')) : '';
if ($inviter !== '') $title = $inviter . ' عازمك على ' . $name . ' في العب';
if ($welcome === '') {
$rows = supabaseService()->get('el3ab_organizations', ['id' => 'eq.' . $org['id'], 'select' => 'description,description_ar', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && $rows) $welcome = trim((string)(($rows[0]['description_ar'] ?? '') ?: ($rows[0]['description'] ?? '')));
}
$desc = $welcome !== '' ? $welcome : 'ادخل ' . $name . ' على العب: شطرنج ولودو ودومينو وطاولة، وبطولات للأعضاء';
$v = substr(md5(json_encode([$org['name'] ?? '', $org['name_ar'] ?? '', $org['hue'] ?? '', $org['logo_url'] ?? '', $org['tag'] ?? ''], JSON_UNESCAPED_UNICODE)), 0, 10);
$image = $base . '/api/share-card.php?org=' . rawurlencode($org['id']) . '&v=' . $v;
} else {
$title = 'عندك دعوة على العب';
foreach ($grants as $g) {
$type = $g['type'] ?? '';
if ($type === 'group_join' && is_array($g['group'] ?? null)) { $title = 'انضم لجروب ' . el3abLinkName($g['group']) . ' على العب'; break; }
if ($type === 'tournament_register' && is_array($g['tournament'] ?? null)) { $title = 'اتسجل في ' . el3abLinkName($g['tournament']) . ' على العب'; break; }
if ($type === 'coins' || $type === 'gems' || $type === 'frame' || $type === 'cosmetic' || $type === 'badge') { $title = 'عندك هدية على العب'; }
}
$desc = $welcome !== '' ? $welcome : 'افتح اللينك وادخل على العب: شطرنج ولودو ودومينو وطاولة';
}
if (mb_strlen($desc) > 200) $desc = mb_substr($desc, 0, 197) . '...';
return ['title' => $title, 'meta' => el3abLinkTags($title, $desc, $image, $link), 'script' => $generic['script']];
} catch (Throwable $e) {
error_log('[link-preview] ' . $e->getMessage());
return $generic;
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -54,6 +54,10 @@ function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?st
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
} elseif ($method === 'PUT') {
// PUT /user: the session's own user (auth.php upgrade-guest).
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body ?? new stdClass()));
}
$response = curl_exec($ch);
......@@ -65,7 +69,8 @@ function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?st
}
$decoded = json_decode($response, true);
if ($httpCode >= 400) {
return ['error' => $decoded['error_description'] ?? $decoded['msg'] ?? $decoded['message'] ?? 'Auth error', 'code' => $httpCode];
return ['error' => $decoded['error_description'] ?? $decoded['msg'] ?? $decoded['message'] ?? 'Auth error', 'code' => $httpCode,
'error_code' => is_array($decoded) ? ($decoded['error_code'] ?? null) : null];
}
return $decoded ?? [];
}
This diff is collapsed.
......@@ -2,6 +2,13 @@
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// Link previews (og: tags) and the invite code of a /r/ link: includes/link-preview.php.
$el3abLink = ['title' => 'EL3AB', 'meta' => '', 'script' => ''];
try {
require_once __DIR__ . '/includes/supabase.php';
require_once __DIR__ . '/includes/link-preview.php';
$el3abLink = el3abLinkPreview();
} catch (Throwable $e) { error_log('[index] link preview: ' . $e->getMessage()); }
?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
......@@ -11,7 +18,8 @@ header('Expires: 0');
<meta name="theme-color" content="#14121A">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>EL3AB</title>
<title><?= htmlspecialchars($el3abLink['title'], ENT_QUOTES, 'UTF-8') ?></title>
<?= $el3abLink['meta'] ?>
<link rel="icon" type="image/png" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/favicon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="apple-touch-icon" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/logo_icon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="manifest" href="/manifest.json">
......@@ -44,6 +52,7 @@ header('Expires: 0');
<div id="overlay"></div>
<div id="toast-container"></div>
<?= $el3abLink['script'] ?>
<script src="/public/js/lib/chess.min.js?v=<?=$v?>"></script>
<script type="module" src="/public/js/engine.js?v=<?=$v?>"></script>
</body>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -9,8 +9,8 @@ Flat "sticker" icons baked by `tools/build-gicons.mjs` (one colour on a darker
lip) from silhouettes at **game-icons.net**, licensed **CC BY 3.0**
(https://creativecommons.org/licenses/by/3.0/). Authors:
- **Lorc** (https://lorcblog.blogspot.com): coin (crown-coin), gem (cut-diamond), laurels, bell, settings, swords, bot, puzzle, lightning, stopwatch, hourglass, lock, search, cross, scroll, shield
- **Delapouite** (https://delapouite.com): coins, trophy, user, portrait, friends, podium, gift, medal, dice, domino, backgammon, ludo (meeple), chat, handshake, shop, bag, star, home, ticket, calendar, check, flag, exit, speaker, mute, edit, trash, party, history, refresh, live, team, sparkles, up, dicefire
- **Lorc** (https://lorcblog.blogspot.com): coin (crown-coin), gem (cut-diamond), laurels, bell, settings, swords, bot, puzzle, lightning, stopwatch, hourglass, lock, search, cross, scroll, shield, key
- **Delapouite** (https://delapouite.com): coins, trophy, user, portrait, friends, podium, gift, medal, dice, domino, backgammon, ludo (meeple), chat, handshake, shop, bag, star, home, ticket, calendar, check, flag, exit, speaker, mute, edit, trash, party, history, refresh, live, team, sparkles, up, dicefire, megaphone, whistle, castle
- **Skoll**: chess, king, chest, heart, spade, diamond, club, and the chess pieces in the game art
- **Quoting**: cards
- **Carl Olsen** (https://twitter.com/unstoppableCarl): flame
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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