diff --git a/src/Domain/Line/ActiveCodeService.php b/src/Domain/Line/ActiveCodeService.php index e7fab163..80b0b8db 100644 --- a/src/Domain/Line/ActiveCodeService.php +++ b/src/Domain/Line/ActiveCodeService.php @@ -266,6 +266,7 @@ class ActiveCodeService { $now = time(); // ─── First-Time Activation (Countdown starts now) ─── + $claimed = false; if ($codeRow['status'] == 1 || empty($codeRow['activated_at'])) { $duration = intval($codeRow['is_trial'] ? ($package['trial_duration'] ?? 1) : ($package['official_duration'] ?? 1)); $unit = (string)($codeRow['is_trial'] ? ($package['trial_duration_in'] ?? 'days') : ($package['official_duration_in'] ?? 'months')); @@ -279,37 +280,55 @@ class ActiveCodeService { $deviceId = !empty($deviceInfo['device_id']) ? trim($deviceInfo['device_id']) : null; $clientIp = $deviceInfo['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? null); - // Update activation_codes + // Claim the code. The WHERE repeats the check above in SQL, so of two + // requests that read the code while it was still fresh only one binds + // its device and starts the countdown; the other finds it taken. $db->query( "UPDATE `activation_codes` SET `status` = 2, `activated_at` = ?, `mac` = COALESCE(?, `mac`), `device_id` = COALESCE(?, `device_id`) - WHERE `id` = ?;", + WHERE `id` = ? AND (`status` = 1 OR `activated_at` IS NULL);", $now, $mac, $deviceId, $codeRow['id'] ); + $claimed = $db->num_rows() > 0; - // Update companion line - $db->query( - "UPDATE `lines` SET - `exp_date` = ?, - `last_ip` = ?, - `last_activity` = ? - WHERE `id` = ?;", - $expDate, - $clientIp, - $now, - $line['id'] - ); + if ($claimed) { + // Update companion line + $db->query( + "UPDATE `lines` SET + `exp_date` = ?, + `last_ip` = ?, + `last_activity` = ? + WHERE `id` = ?;", + $expDate, + $clientIp, + $now, + $line['id'] + ); - $line['exp_date'] = $expDate; - $codeRow['status'] = 2; - $codeRow['activated_at'] = $now; - } else { + $line['exp_date'] = $expDate; + $codeRow['status'] = 2; + $codeRow['activated_at'] = $now; + } else { + // Another request activated it first: from here it is an activated + // code like any other, device lock included. + $codeRow = self::getByCode($cleanCode); + $line = $codeRow ? UserRepository::getLineById($codeRow['subscriber_id']) : null; + if (!$codeRow || !$line) { + return ['status' => 'INVALID_CODE', 'message' => 'Invalid or unknown activation code.']; + } + if ($codeRow['status'] == 0) { + return ['status' => 'DISABLED', 'message' => 'This activation code has been suspended or revoked.']; + } + } + } + + if (!$claimed) { // Already activated: check if expired if (!empty($line['exp_date']) && $line['exp_date'] < $now) { return [ @@ -320,8 +339,10 @@ class ActiveCodeService { ]; } - // Check device lock if enforced - if (!empty($codeRow['mac']) && !empty($deviceInfo['mac']) && strcasecmp($codeRow['mac'], $deviceInfo['mac']) !== 0) { + // A code bound to a device answers that device only. A request naming + // no device is not that device: skipping the check when `mac` was absent + // let any client read a locked code's credentials by leaving it out. + if (!empty($codeRow['mac']) && strcasecmp($codeRow['mac'], trim((string) ($deviceInfo['mac'] ?? ''))) !== 0) { return ['status' => 'DEVICE_MISMATCH', 'message' => 'Code is locked to another hardware device.']; } } @@ -348,7 +369,7 @@ class ActiveCodeService { return [ 'status' => 'SUCCESS', 'code' => $cleanCode, - 'is_new_activation' => ($codeRow['status'] == 2 && ($codeRow['activated_at'] >= ($now - 5))), + 'is_new_activation' => $claimed, 'package_name' => $package['package_name'] ?? 'Premium IPTV', 'exp_date' => (int)$line['exp_date'], 'exp_date_formatted' => date('Y-m-d H:i:s', (int)$line['exp_date']), diff --git a/tests/Unit/ActiveCodeActivationTest.php b/tests/Unit/ActiveCodeActivationTest.php new file mode 100644 index 00000000..3b8c8800 --- /dev/null +++ b/tests/Unit/ActiveCodeActivationTest.php @@ -0,0 +1,158 @@ +dbh = true; // Database's helpers only test it for truthiness + } + + public function query($query, $buffered = false) { + $rBinds = array_slice(func_get_args(), 1); + $this->rows = []; + $this->affected = 0; + + if (str_contains($query, 'FROM `activation_codes` WHERE `activation_code` = ?')) { + if ($this->staleCode !== null) { + $this->rows = [$this->staleCode]; + $this->staleCode = null; + } else { + $this->rows = array_values(array_filter($this->codes, fn($r) => $r['activation_code'] === $rBinds[0])); + } + } elseif (str_contains($query, 'FROM `lines` WHERE `id` = ?')) { + $this->rows = isset($this->lines[$rBinds[0]]) ? [$this->lines[$rBinds[0]]] : []; + } elseif (str_contains($query, 'FROM `users_packages` WHERE `id` = ?')) { + $this->rows = isset($this->packages[$rBinds[0]]) ? [$this->packages[$rBinds[0]]] : []; + } elseif (str_starts_with(trim($query), 'UPDATE `activation_codes`')) { + [$rAt, $rMac, $rDevice, $rID] = $rBinds; + $rRow = &$this->codes[$rID]; + // The claim: when the statement carries the "still unactivated" guard, + // a row another request already activated is left alone. + if (str_contains($query, '(`status` = 1 OR `activated_at` IS NULL)') && $rRow['status'] != 1 && $rRow['activated_at'] !== null) { + return true; + } + $rRow['status'] = 2; + $rRow['activated_at'] = $rAt; + $rRow['mac'] = $rMac ?? $rRow['mac']; + $rRow['device_id'] = $rDevice ?? $rRow['device_id']; + $this->affected = 1; + } elseif (str_starts_with(trim($query), 'UPDATE `lines`')) { + [$rExp, $rIP, $rNow, $rID] = $rBinds; + $this->lines[$rID]['exp_date'] = $rExp; + $this->affected = 1; + } + + return true; + } + + public function num_rows() { + return $this->rows ? count($this->rows) : $this->affected; + } + + public function get_row() { + return $this->rows[0] ?? []; + } +} + +class ActiveCodeActivationTest extends TestCase { + private ActivationScriptedDb $db; + + protected function setUp(): void { + if (!defined('SERVER_ID')) { + define('SERVER_ID', 1); + } + $GLOBALS['rSettings'] = ['keep_protocol' => 0, 'use_mdomain_in_lists' => 0]; + $GLOBALS['rServers'] = [1 => [ + 'server_protocol' => 'http', 'enable_proxy' => 0, 'domain_name' => 'panel.test', + 'server_ip' => '10.0.0.1', 'http_broadcast_port' => 80, 'https_broadcast_port' => 443, 'server_type' => 0, + ]]; + + $this->db = new ActivationScriptedDb(); + $this->db->packages[3] = ['id' => 3, 'package_name' => 'P', 'official_duration' => 1, 'official_duration_in' => 'months']; + $this->db->lines[7] = ['id' => 7, 'username' => 'u7', 'password' => 'p7', 'exp_date' => time() + 86400, 'max_connections' => 1]; + DatabaseFactory::set($this->db); + } + + private function code(array $rOver): array { + return array_merge([ + 'id' => 1, 'activation_code' => 'ABCD234567', 'status' => 2, 'subscriber_id' => 7, 'package_id' => 3, + 'is_trial' => 0, 'mac' => null, 'device_id' => null, 'activated_at' => time() - 3600, 'dns_base' => '', + ], $rOver); + } + + /** A code locked to a device must not hand its credentials to a request that simply names no device. */ + public function testLockedCodeRefusesARequestWithoutAMac(): void { + $this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC']); + + $rRes = ActiveCodeService::activateCode('ABCD234567', ['ip' => '192.0.2.9']); + + $this->assertSame('DEVICE_MISMATCH', $rRes['status']); + $this->assertArrayNotHasKey('credentials', $rRes); + } + + public function testLockedCodeServesItsOwnDevice(): void { + $this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC']); + + $rRes = ActiveCodeService::activateCode('abcd234567', ['mac' => '00:1a:79:aa:bb:cc']); + + $this->assertSame('SUCCESS', $rRes['status']); + $this->assertSame('u7', $rRes['credentials']['username']); + $this->assertFalse($rRes['is_new_activation']); + } + + public function testUnboundCodeServesAnyone(): void { + $this->db->codes[1] = $this->code([]); + + $this->assertSame('SUCCESS', ActiveCodeService::activateCode('ABCD234567', [])['status']); + } + + public function testFirstActivationBindsTheDeviceAndStartsTheCountdown(): void { + $this->db->codes[1] = $this->code(['status' => 1, 'activated_at' => null]); + $this->db->lines[7]['exp_date'] = null; + + $rRes = ActiveCodeService::activateCode('ABCD234567', ['mac' => '00:1A:79:AA:BB:CC']); + + $this->assertSame('SUCCESS', $rRes['status']); + $this->assertTrue($rRes['is_new_activation']); + $this->assertSame('00:1A:79:AA:BB:CC', $this->db->codes[1]['mac']); + $this->assertGreaterThan(time() + 27 * 86400, $this->db->lines[7]['exp_date']); + } + + /** + * Two requests read a fresh code at once. The first binds its device; the + * second must not re-bind the code to itself or restart the countdown — it + * meets the lock like any later request. + */ + public function testTheLoserOfAnActivationRaceMeetsTheLock(): void { + $rActivatedAt = time() - 2; + $rExp = time() + 30 * 86400 - 2; + $this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC', 'activated_at' => $rActivatedAt]); + $this->db->lines[7]['exp_date'] = $rExp; + $this->db->staleCode = $this->code(['status' => 1, 'activated_at' => null]); // what the loser read + + $rRes = ActiveCodeService::activateCode('ABCD234567', ['mac' => '00:1A:79:DD:EE:FF']); + + $this->assertSame('DEVICE_MISMATCH', $rRes['status']); + $this->assertSame('00:1A:79:AA:BB:CC', $this->db->codes[1]['mac'], 'the winner keeps the device'); + $this->assertSame($rActivatedAt, $this->db->codes[1]['activated_at']); + $this->assertSame($rExp, $this->db->lines[7]['exp_date'], 'the countdown is not restarted'); + } +}