diff --git a/src/Core/Auth/AuthRepository.php b/src/Core/Auth/AuthRepository.php index f67915db..47f61ea9 100644 --- a/src/Core/Auth/AuthRepository.php +++ b/src/Core/Auth/AuthRepository.php @@ -63,6 +63,34 @@ class AuthRepository { return $rCodes; } + /** + * Get the active code string for Web Player (type 6), if configured and enabled. + * + * @return string|null + */ + public static function getWebPlayerCode(): ?string { + foreach (self::getAllCodes(6) as $code) { + if (!empty($code['enabled'])) { + return (string)$code['code']; + } + } + return null; + } + + /** + * Get the active code string for Active Code Portal (type 7), if configured and enabled. + * + * @return string|null + */ + public static function getActiveCodePortalCode(): ?string { + foreach (self::getAllCodes(7) as $code) { + if (!empty($code['enabled'])) { + return (string)$code['code']; + } + } + return null; + } + /** * Regenerate per-code nginx config files from the database and reload nginx. * @@ -95,9 +123,13 @@ class AuthRepository { // NOTE: 'includes/api/admin' and 'includes/api/reseller' are legacy nginx route // identifiers baked into generated access-code configs — NOT filesystem paths. // Do not rename without regenerating all deployed nginx configs. - $rType = array('admin', 'reseller', 'ministra', 'includes/api/admin', 'includes/api/reseller', 'ministra/new', 'player')[$rCode['type']]; - $rAlias = array('Public/Views/admin', 'reseller', 'Ministra', 'includes/api/admin', 'includes/api/reseller', 'Ministra/new', 'Public/assets/player')[$rCode['type']]; - $rBurst = array(500, 50, 50, 1000, 1000, 50, 500)[$rCode['type']]; + $rTypeMap = [0 => 'admin', 1 => 'reseller', 2 => 'ministra', 3 => 'includes/api/admin', 4 => 'includes/api/reseller', 5 => 'ministra/new', 6 => 'player', 7 => 'portal']; + $rAliasMap = [0 => 'Public/Views/admin', 1 => 'reseller', 2 => 'Ministra', 3 => 'includes/api/admin', 4 => 'includes/api/reseller', 5 => 'Ministra/new', 6 => 'Public/assets/player', 7 => 'Public/Views/portal']; + $rBurstMap = [0 => 500, 1 => 50, 2 => 50, 3 => 1000, 4 => 1000, 5 => 50, 6 => 500, 7 => 500]; + + $rType = $rTypeMap[(int)$rCode['type']] ?? 'admin'; + $rAlias = $rAliasMap[(int)$rCode['type']] ?? 'Public/Views/admin'; + $rBurst = $rBurstMap[(int)$rCode['type']] ?? 500; $rCurrentTemplate = in_array($rType, array('ministra', 'ministra/new')) ? $rMinistraTemplate : $rTemplate; if (in_array($rType, array('ministra', 'ministra/new')) || strlen($rCode['code']) >= 4) { diff --git a/src/Core/Auth/AuthService.php b/src/Core/Auth/AuthService.php index 89ea1fd4..eb03c851 100644 --- a/src/Core/Auth/AuthService.php +++ b/src/Core/Auth/AuthService.php @@ -65,7 +65,11 @@ class AuthService { $rArray['whitelist'] = '[]'; } - if ($rData['type'] != 2 && strlen($rData['code']) < 8) { + if (in_array((int)$rData['type'], [6, 7], true)) { + if (strlen($rData['code']) < 3) { + return array('status' => STATUS_CODE_LENGTH, 'data' => $rData); + } + } elseif ($rData['type'] != 2 && strlen($rData['code']) < 8) { return array('status' => STATUS_CODE_LENGTH, 'data' => $rData); } diff --git a/src/Core/Module/CoreNavbarProvider.php b/src/Core/Module/CoreNavbarProvider.php index bf3bbc36..d986f70c 100644 --- a/src/Core/Module/CoreNavbarProvider.php +++ b/src/Core/Module/CoreNavbarProvider.php @@ -130,6 +130,23 @@ class CoreNavbarProvider implements NavbarProviderInterface { ->parent('users.lines')->url('line_mass') ->label('mass_edit_lines')->permissions(['mass_edit_lines'])->order(30)); + // Active Codes + NavbarRegistry::add((new NavbarItem('users.active_codes')) + ->parent('users')->url('#') + ->label('active_codes')->permissions(['add_user', 'users'])->order(15)); + NavbarRegistry::add((new NavbarItem('users.active_codes.add')) + ->parent('users.active_codes')->url('active_code') + ->label('generate_codes')->permissions(['add_user'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.active_codes.manage')) + ->parent('users.active_codes')->url('active_codes') + ->label('manage_active_codes')->permissions(['users'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.active_codes.batch')) + ->parent('users.active_codes')->url('active_codes_batch') + ->label('batch_manager')->permissions(['users'])->order(25)); + NavbarRegistry::add((new NavbarItem('users.active_codes.mass')) + ->parent('users.active_codes')->url('active_codes_mass') + ->label('mass_edit_active_codes')->permissions(['mass_edit_lines'])->order(30)); + // MAG NavbarRegistry::add((new NavbarItem('users.mag')) ->parent('users')->url('#') diff --git a/src/Domain/Line/ActiveCodeService.php b/src/Domain/Line/ActiveCodeService.php new file mode 100644 index 00000000..51390d63 --- /dev/null +++ b/src/Domain/Line/ActiveCodeService.php @@ -0,0 +1,657 @@ +query('SELECT `id` FROM `activation_codes` WHERE `activation_code` = ? LIMIT 1;', $code); + } while ($db->num_rows() > 0); + + return $code; + } + + /** + * Generate unique batch name. + */ + public static function generateBatchName(): string { + return 'BATCH-' . date('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 5)); + } + + /** + * Generate single or bulk active codes with transaction safety. + * + * @param array $data Input form parameters + * @param array $user Authenticated user + * @param bool $isAdmin Is administrator + * @return array Result array with status, message, count, and codes + */ + public static function generateCodes(array $data, array $user, bool $isAdmin): array { + $db = self::db(); + + $qty = max(1, min(500, intval($data['num_codes'] ?? 1))); + $length = max(6, min(24, intval($data['code_length'] ?? 10))); + $format = ($data['code_format'] ?? 'alphanumeric') === 'numeric' ? 'numeric' : 'alphanumeric'; + + $packageId = intval($data['package_id'] ?? 0); + $package = PackageService::getById($packageId); + if (!$package) { + return ['status' => 'ERROR', 'message' => 'Invalid package selected.']; + } + + // Calculate credit cost per code + $isTrial = !empty($package['is_trial']) || !empty($data['is_trial']); + if ($isTrial) { + $costPerCode = floatval($package['trial_credits'] ?? 0); + } else { + // Check for reseller custom package override + $override = json_decode($user['override_packages'] ?? '', true) ?: []; + if (isset($override[$packageId]['official_credits']) && strlen((string)$override[$packageId]['official_credits']) > 0) { + $costPerCode = floatval($override[$packageId]['official_credits']); + } else { + $costPerCode = floatval($package['official_credits'] ?? 0); + } + } + + $totalCost = $qty * $costPerCode; + + // Balance check for non-admin + if (!$isAdmin) { + $currentCredits = floatval($user['credits'] ?? 0); + if ($totalCost > $currentCredits) { + return [ + 'status' => 'INSUFFICIENT_CREDITS', + 'message' => "Insufficient balance. Required: {$totalCost} credits, Available: {$currentCredits} credits." + ]; + } + } + + // Target owner for codes + $targetOwnerId = $user['id']; + if ($isAdmin && !empty($data['created_by'])) { + $targetOwnerId = intval($data['created_by']); + } + + $batchName = trim($data['batch_name'] ?? ''); + if (empty($batchName)) { + $batchName = self::generateBatchName(); + } + + // Bouquets determination + if (!empty($data['bouquets_selected']) && is_array($data['bouquets_selected'])) { + $selectedBouquets = array_map('intval', $data['bouquets_selected']); + } else { + $selectedBouquets = json_decode((string)($package['bouquets'] ?? '[]'), true) ?: []; + } + $bouquetsJson = '[' . implode(',', array_map('intval', $selectedBouquets)) . ']'; + + $dnsBase = trim($data['dns_base'] ?? '') ?: null; + $forcedCountry = trim($data['forced_country'] ?? '') ?: ($package['forced_country'] ?? null); + $maxConnections = intval($data['max_connections'] ?? ($package['max_connections'] ?: 1)); + $isAdult = !empty($data['is_adult']) ? 1 : 0; + $outputFormats = $package['output_formats'] ?? '[]'; + + $generatedCodes = []; + + $db->beginTransaction(); + try { + // 1. Deduct reseller credits if non-admin + if (!$isAdmin && $totalCost > 0) { + $newCredits = floatval($user['credits']) - $totalCost; + $db->query('UPDATE `users` SET `credits` = ? WHERE `id` = ?;', $newCredits, $user['id']); + + // Audit logging + $db->query( + "INSERT INTO `users_credits_logs` (`target_id`, `admin_id`, `amount`, `date`, `reason`) VALUES (?, ?, ?, ?, ?);", + $user['id'], + $user['id'], + -$totalCost, + time(), + "Generated {$qty} active codes for package: {$package['package_name']} (Batch: {$batchName})" + ); + + $db->query( + "INSERT INTO `users_logs` (`owner`, `type`, `action`, `package_id`, `cost`, `credits_after`, `date`, `deleted_info`) VALUES (?, 'active_code', 'generate', ?, ?, ?, ?, ?);", + $user['id'], + $packageId, + $totalCost, + $newCredits, + time(), + json_encode(['qty' => $qty, 'batch_name' => $batchName, 'package' => $package['package_name']]) + ); + } + + // 2. Generate subscriber lines & activation codes + for ($i = 0; $i < $qty; $i++) { + $code = self::generateCodeString($length, $format); + + // Auto-create companion line with frozen countdown (exp_date = NULL) + $lineUsername = 'ac_' . strtolower(substr(bin2hex(random_bytes(5)), 0, 9)); + $linePassword = substr(bin2hex(random_bytes(6)), 0, 10); + + // Ensure username collision-free + while (UserRepository::getLineByUsername($lineUsername)) { + $lineUsername = 'ac_' . strtolower(substr(bin2hex(random_bytes(5)), 0, 9)); + } + + $db->query( + "INSERT INTO `lines` ( + `member_id`, `username`, `password`, `exp_date`, `admin_enabled`, `enabled`, + `bouquet`, `allowed_outputs`, `max_connections`, `is_restreamer`, `is_trial`, + `is_mag`, `is_e2`, `forced_country`, `package_id`, `is_activecode`, `created_at`, + `reseller_notes` + ) VALUES (?, ?, ?, NULL, 1, 1, ?, ?, ?, 0, ?, 0, 0, ?, ?, 1, ?, ?);", + $targetOwnerId, + $lineUsername, + $linePassword, + $bouquetsJson, + $outputFormats, + $maxConnections, + $isTrial ? 1 : 0, + $forcedCountry, + $packageId, + time(), + "Active Code: {$code} (Batch: {$batchName})" + ); + + $lineId = (int)$db->last_insert_id(); + + // Insert into activation_codes table + $db->query( + "INSERT INTO `activation_codes` ( + `activation_code`, `batch_name`, `subscriber_id`, `status`, `created_by`, + `package_id`, `bouquets`, `is_adult`, `is_trial`, `purchase_cost`, + `dns_base`, `forced_country`, `max_connections`, `created_at` + ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + $code, + $batchName, + $lineId, + $targetOwnerId, + $packageId, + $bouquetsJson, + $isAdult, + $isTrial ? 1 : 0, + $costPerCode, + $dnsBase, + $forcedCountry, + $maxConnections, + time() + ); + + $generatedCodes[] = [ + 'code' => $code, + 'line_id' => $lineId, + 'username' => $lineUsername, + 'password' => $linePassword, + 'batch_name' => $batchName, + ]; + } + + $db->commit(); + + return [ + 'status' => 'SUCCESS', + 'message' => "Successfully generated {$qty} active codes.", + 'batch_name' => $batchName, + 'qty' => $qty, + 'total_cost' => $totalCost, + 'codes' => $generatedCodes, + ]; + } catch (\Throwable $e) { + $db->rollback(); + return [ + 'status' => 'ERROR', + 'message' => 'Failed to generate codes: ' . $e->getMessage() + ]; + } + } + + /** + * Activate a code or verify an existing active code. + * Starts the subscription timer countdown on first access (Stock Mode -> Active). + * + * @param string $code Activation code + * @param array $deviceInfo Client device details (mac, device_id, ip, user_agent) + * @return array Result with status, line info, M3U playlists, XC credentials + */ + public static function activateCode(string $code, array $deviceInfo = []): array { + $db = self::db(); + $cleanCode = strtoupper(trim($code)); + + $codeRow = self::getByCode($cleanCode); + if (!$codeRow) { + return ['status' => 'INVALID_CODE', 'message' => 'Invalid or unknown activation code.']; + } + + // Revoked or disabled + if ($codeRow['status'] == 0) { + return ['status' => 'DISABLED', 'message' => 'This activation code has been suspended or revoked.']; + } + + $line = UserRepository::getLineById($codeRow['subscriber_id']); + if (!$line) { + return ['status' => 'LINE_NOT_FOUND', 'message' => 'Underlying subscription line not found.']; + } + + $package = PackageService::getById($codeRow['package_id']); + $now = time(); + + // ─── First-Time Activation (Countdown starts now) ─── + 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')); + + if (!in_array($unit, ['hours', 'days', 'months', 'years'], true)) { + $unit = 'months'; + } + + $expDate = strtotime("+{$duration} {$unit}", $now); + $mac = !empty($deviceInfo['mac']) ? trim($deviceInfo['mac']) : null; + $deviceId = !empty($deviceInfo['device_id']) ? trim($deviceInfo['device_id']) : null; + $clientIp = $deviceInfo['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? null); + + // Update activation_codes + $db->query( + "UPDATE `activation_codes` SET + `status` = 2, + `activated_at` = ?, + `mac` = COALESCE(?, `mac`), + `device_id` = COALESCE(?, `device_id`) + WHERE `id` = ?;", + $now, + $mac, + $deviceId, + $codeRow['id'] + ); + + // 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 { + // Already activated: check if expired + if (!empty($line['exp_date']) && $line['exp_date'] < $now) { + return [ + 'status' => 'EXPIRED', + 'message' => 'Subscription has expired.', + 'exp_date' => $line['exp_date'], + 'code' => $cleanCode, + ]; + } + + // Check device lock if enforced + if (!empty($codeRow['mac']) && !empty($deviceInfo['mac']) && strcasecmp($codeRow['mac'], $deviceInfo['mac']) !== 0) { + return ['status' => 'DEVICE_MISMATCH', 'message' => 'Code is locked to another hardware device.']; + } + } + + // Resolve Portal and M3U URLs + $portalHost = DomainResolver::resolve(SERVER_ID); + if (!empty($codeRow['dns_base'])) { + $portalHost = rtrim($codeRow['dns_base'], '/'); + } + $portalParsed = parse_url($portalHost); + $serverDomain = $portalParsed['host'] ?? $_SERVER['HTTP_HOST'] ?? 'localhost'; + $serverPort = $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80); + + $m3uHls = "{$portalHost}/get.php?username={$line['username']}&password={$line['password']}&type=m3u_plus&output=hls"; + $m3uTs = "{$portalHost}/get.php?username={$line['username']}&password={$line['password']}&type=m3u_plus&output=ts"; + + $webPlayerUrl = null; + $db->query("SELECT `code` FROM `access_codes` WHERE `type` = 6 AND `enabled` = 1 LIMIT 1;"); + if ($db->num_rows() > 0) { + $wpRow = $db->get_row(); + $webPlayerUrl = "{$portalHost}/{$wpRow['code']}/"; + } + + return [ + 'status' => 'SUCCESS', + 'code' => $cleanCode, + 'is_new_activation' => ($codeRow['status'] == 2 && ($codeRow['activated_at'] >= ($now - 5))), + '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']), + 'max_connections' => (int)($line['max_connections'] ?? 1), + 'line' => $line, + 'web_player_url' => $webPlayerUrl, + 'credentials' => [ + 'server' => $serverDomain, + 'port' => $serverPort, + 'host' => $portalHost, + 'username' => $line['username'], + 'password' => $line['password'], + ], + 'playlists' => [ + 'm3u_hls' => $m3uHls, + 'm3u_ts' => $m3uTs, + ], + 'device' => [ + 'mac' => $codeRow['mac'], + 'device_id' => $codeRow['device_id'], + ], + 'code_details' => $codeRow, + ]; + } + + /** + * Look up activation code record by code string. + */ + public static function getByCode(string $code): ?array { + $db = self::db(); + $db->query('SELECT * FROM `activation_codes` WHERE `activation_code` = ? LIMIT 1;', strtoupper(trim($code))); + return $db->num_rows() > 0 ? $db->get_row() : null; + } + + /** + * Look up activation code record by ID. + */ + public static function getById(int $id): ?array { + $db = self::db(); + $db->query('SELECT * FROM `activation_codes` WHERE `id` = ? LIMIT 1;', $id); + return $db->num_rows() > 0 ? $db->get_row() : null; + } + + /** + * Distinct resellers who have created activation codes (reseller filter). + */ + public static function getResellersWithCodes(): array { + $db = self::db(); + return $db->fetchAll( + 'SELECT DISTINCT `users`.`id`, `users`.`username` + FROM `activation_codes` + INNER JOIN `users` ON `users`.`id` = `activation_codes`.`created_by` + ORDER BY `users`.`username` ASC;' + ); + } + + /** + * Recent distinct batch names (batch filter). Pass a list of creator ids to + * scope it (reseller view); empty = all batches (admin view). + */ + public static function getRecentBatchNames(array $createdBy = [], int $limit = 100): array { + $db = self::db(); + $where = '`batch_name` IS NOT NULL'; + if (!empty($createdBy)) { + $where = '`created_by` IN (' . implode(',', array_map('intval', $createdBy)) . ') AND ' . $where; + } + return $db->fetchAll( + 'SELECT DISTINCT `batch_name` FROM `activation_codes` + WHERE ' . $where . ' + ORDER BY `created_at` DESC LIMIT ' . (int) $limit . ';' + ); + } + + /** + * All resellers with their credit balance, for the creator-assignment + * dropdown on the admin generate-codes wizard. + */ + public static function getResellersForAssignment(): array { + $db = self::db(); + return $db->fetchAll('SELECT `id`, `username`, `credits` FROM `users` ORDER BY `username` ASC;'); + } + + /** + * Multi-action / Mass edit engine. + */ + public static function massAction(string $action, array $codeIds, array $user, bool $isAdmin, array $extra = []): array { + $db = self::db(); + if (empty($codeIds)) { + return ['status' => 'ERROR', 'message' => 'No codes selected.']; + } + + $cleanIds = array_map('intval', $codeIds); + $idList = implode(',', $cleanIds); + + // Security check: restrict non-admins to their report tree + $whereScope = ''; + if (!$isAdmin) { + $allowedReports = (array)($user['reports'] ?? [$user['id']]); + $reportsList = implode(',', array_map('intval', $allowedReports)); + $whereScope = " AND `created_by` IN ({$reportsList})"; + } + + // Fetch targets + $codes = $db->fetchAll("SELECT * FROM `activation_codes` WHERE `id` IN ({$idList}) {$whereScope};"); + if (empty($codes)) { + return ['status' => 'ERROR', 'message' => 'No accessible codes found for mass action.']; + } + + $targetIds = array_column($codes, 'id'); + $targetIdList = implode(',', $targetIds); + $subscriberIds = array_filter(array_column($codes, 'subscriber_id')); + $subIdList = !empty($subscriberIds) ? implode(',', $subscriberIds) : '0'; + + switch ($action) { + case 'mass_enable': + // Set status: 1 if never activated, 2 if activated + $db->query("UPDATE `activation_codes` SET `status` = IF(`activated_at` IS NULL, 1, 2) WHERE `id` IN ({$targetIdList});"); + $db->query("UPDATE `lines` SET `enabled` = 1, `admin_enabled` = 1 WHERE `id` IN ({$subIdList});"); + return ['status' => 'SUCCESS', 'message' => count($targetIds) . ' codes successfully enabled.']; + + case 'mass_disable': + $db->query("UPDATE `activation_codes` SET `status` = 0 WHERE `id` IN ({$targetIdList});"); + $db->query("UPDATE `lines` SET `enabled` = 0 WHERE `id` IN ({$subIdList});"); + return ['status' => 'SUCCESS', 'message' => count($targetIds) . ' codes suspended.']; + + case 'mass_extend': + $days = max(1, intval($extra['days'] ?? 30)); + $seconds = $days * 86400; + $db->query( + "UPDATE `lines` SET `exp_date` = IF(`exp_date` IS NOT NULL AND `exp_date` > UNIX_TIMESTAMP(), `exp_date` + ?, UNIX_TIMESTAMP() + ?) WHERE `id` IN ({$subIdList}) AND `exp_date` IS NOT NULL;", + $seconds, + $seconds + ); + return ['status' => 'SUCCESS', 'message' => "Extended expiration of selected active codes by {$days} days."]; + + case 'mass_reset_device': + $db->query("UPDATE `activation_codes` SET `mac` = NULL, `device_id` = NULL WHERE `id` IN ({$targetIdList});"); + return ['status' => 'SUCCESS', 'message' => 'Hardware/Device lock reset on selected codes.']; + + case 'mass_change_package': + $newPackageId = intval($extra['package_id'] ?? 0); + $newPackage = PackageService::getById($newPackageId); + if (!$newPackage) { + return ['status' => 'ERROR', 'message' => 'Invalid package.']; + } + $newBouquets = $newPackage['bouquets']; + $db->query("UPDATE `activation_codes` SET `package_id` = ?, `bouquets` = ? WHERE `id` IN ({$targetIdList});", $newPackageId, $newBouquets); + $db->query("UPDATE `lines` SET `package_id` = ?, `bouquet` = ? WHERE `id` IN ({$subIdList});", $newPackageId, $newBouquets); + return ['status' => 'SUCCESS', 'message' => 'Updated package on selected codes.']; + + case 'mass_delete': + $refund = !empty($extra['refund_credits']) || !empty($extra['refund']); + $totalRefunded = 0; + + $db->beginTransaction(); + try { + if ($refund && !$isAdmin) { + // Calculate refund only on stock/unactivated codes (status = 1) + foreach ($codes as $c) { + if ($c['status'] == 1 && $c['purchase_cost'] > 0 && $c['created_by'] == $user['id']) { + $totalRefunded += floatval($c['purchase_cost']); + } + } + + if ($totalRefunded > 0) { + $db->query("UPDATE `users` SET `credits` = `credits` + ? WHERE `id` = ?;", $totalRefunded, $user['id']); + $db->query( + "INSERT INTO `users_credits_logs` (`target_id`, `admin_id`, `amount`, `date`, `reason`) VALUES (?, ?, ?, ?, ?);", + $user['id'], + $user['id'], + $totalRefunded, + time(), + "Refund for deleted unused active codes (" . count($targetIds) . " codes)" + ); + } + } + + $db->query("DELETE FROM `activation_codes` WHERE `id` IN ({$targetIdList});"); + $db->query("DELETE FROM `lines` WHERE `id` IN ({$subIdList});"); + $db->commit(); + + $msg = count($targetIds) . ' codes deleted successfully.'; + if ($totalRefunded > 0) { + $msg .= " Refunded {$totalRefunded} credits for unused stock."; + } + return ['status' => 'SUCCESS', 'message' => $msg]; + } catch (\Throwable $e) { + $db->rollback(); + return ['status' => 'ERROR', 'message' => 'Failed to delete codes: ' . $e->getMessage()]; + } + + default: + return ['status' => 'ERROR', 'message' => 'Unknown mass action.']; + } + } + + /** + * Get grouped summary metrics by batch. + */ + public static function getBatchSummary(array $user, bool $isAdmin, ?string $batchName = null): array { + $db = self::db(); + $where = []; + $params = []; + + if (!$isAdmin) { + $allowedReports = (array)($user['reports'] ?? [$user['id']]); + $where[] = "`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ")"; + } + + if ($batchName) { + $where[] = "`batch_name` = ?"; + $params[] = $batchName; + } + + $whereClause = !empty($where) ? ('WHERE ' . implode(' AND ', $where)) : ''; + + $sql = "SELECT + `batch_name`, + `created_by`, + `package_id`, + `is_trial`, + MIN(`created_at`) as `created_at`, + COUNT(*) as `total_codes`, + SUM(CASE WHEN `status` = 1 THEN 1 ELSE 0 END) as `stock_count`, + SUM(CASE WHEN `status` = 2 THEN 1 ELSE 0 END) as `active_count`, + SUM(CASE WHEN `status` = 0 THEN 1 ELSE 0 END) as `disabled_count` + FROM `activation_codes` + {$whereClause} + GROUP BY `batch_name`, `created_by`, `package_id`, `is_trial` + ORDER BY `created_at` DESC;"; + + $rows = $db->fetchAll($sql, ...$params); + $packages = []; + $resellers = []; + + foreach ($rows as &$row) { + $pkgId = $row['package_id']; + if (!isset($packages[$pkgId])) { + $p = PackageService::getById($pkgId); + $packages[$pkgId] = $p['package_name'] ?? 'Custom Package'; + } + $row['package_name'] = $packages[$pkgId]; + + $resellerId = $row['created_by']; + if (!isset($resellers[$resellerId])) { + $u = UserRepository::getUserById($resellerId); + $resellers[$resellerId] = $u['username'] ?? "User #{$resellerId}"; + } + $row['creator_name'] = $resellers[$resellerId]; + $row['ready_codes'] = $row['stock_count']; + $row['active_codes'] = $row['active_count']; + } + + return $rows; + } + + /** + * Export scratch-card formatted text for printing. + */ + public static function exportBatchTxt(string $batchName, array $user, bool $isAdmin): string { + $db = self::db(); + $where = ["`batch_name` = ?"]; + $params = [$batchName]; + + if (!$isAdmin) { + $allowedReports = (array)($user['reports'] ?? [$user['id']]); + $where[] = "`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ")"; + } + + $whereClause = 'WHERE ' . implode(' AND ', $where); + $codes = $db->fetchAll("SELECT * FROM `activation_codes` {$whereClause} ORDER BY `id` ASC;", ...$params); + + if (empty($codes)) { + return "No codes found for batch {$batchName}.\n"; + } + + $first = $codes[0]; + $pkg = PackageService::getById($first['package_id']); + $pkgName = $pkg['package_name'] ?? 'IPTV Subscription'; + $portalUrl = DomainResolver::resolve(SERVER_ID); + if (!empty($first['dns_base'])) { + $portalUrl = rtrim($first['dns_base'], '/'); + } + + $out = "========================================================================\n"; + $out .= " ACTIVATION CODES VOUCHER BATCH \n"; + $out .= "========================================================================\n"; + $out .= "Batch Name : {$batchName}\n"; + $out .= "Package : {$pkgName}\n"; + $out .= "Generated : " . date('Y-m-d H:i:s', (int)$first['created_at']) . "\n"; + $out .= "Total Vouchers: " . count($codes) . "\n"; + $out .= "Activation Portal: {$portalUrl}/portal\n"; + $out .= "========================================================================\n\n"; + + $i = 1; + foreach ($codes as $c) { + $statusText = ($c['status'] == 1) ? 'READY / UNUSED' : (($c['status'] == 2) ? 'ACTIVE' : 'REVOKED'); + $out .= "+----------------------------------------------------------------------+\n"; + $out .= sprintf("| CARD #%03d | CODE: %-25s | %-16s |\n", $i++, $c['activation_code'], $statusText); + $out .= sprintf("| Portal: %-42s Max Conn: %-2d |\n", "{$portalUrl}/portal", (int)$c['max_connections']); + $out .= "+----------------------------------------------------------------------+\n\n"; + } + + return $out; + } +} diff --git a/src/Domain/User/UserRepository.php b/src/Domain/User/UserRepository.php index 5bbe66d8..92b6c3ba 100644 --- a/src/Domain/User/UserRepository.php +++ b/src/Domain/User/UserRepository.php @@ -393,6 +393,22 @@ class UserRepository { return null; } + /** + * Fetch a line by username. + * + * @param string $rUsername Line username. + * @return array|null The line row, or null if not found. + */ + public static function getLineByUsername($rUsername) { + $db = self::db(); + $db->query('SELECT * FROM `lines` WHERE `username` = ?;', $rUsername); + + if ($db->num_rows() == 1) { + return $db->get_row(); + } + return null; + } + /** * Fetch a registered (panel) user by id. * @@ -409,6 +425,16 @@ class UserRepository { return null; } + /** + * Alias for getRegisteredUserById. + * + * @param int $rID User id. + * @return array|null + */ + public static function getUserById($rID) { + return self::getRegisteredUserById($rID); + } + /** * List registered users under an owner. * diff --git a/src/Infrastructure/ResellerApiDispatcher.php b/src/Infrastructure/ResellerApiDispatcher.php index f7d7f90e..242c096d 100644 --- a/src/Infrastructure/ResellerApiDispatcher.php +++ b/src/Infrastructure/ResellerApiDispatcher.php @@ -3,12 +3,14 @@ namespace XcVm\Infrastructure; use XcVm\Core\Auth\Authorization; +use XcVm\Core\Config\DomainResolver; use XcVm\Core\Config\SettingsManager; use XcVm\Core\Http\RequestManager; use XcVm\Core\Util\ImageUtils; use XcVm\Domain\Device\EnigmaService; use XcVm\Domain\Device\MagService; use XcVm\Domain\Epg\EpgService; +use XcVm\Domain\Line\ActiveCodeService; use XcVm\Domain\Line\LineService; use XcVm\Domain\Line\PackageService; use XcVm\Domain\Stream\CategoryService; @@ -46,25 +48,78 @@ class ResellerApiDispatcher { public static function dispatch(string $action, ?array $rUserInfo, array $rPermissions): void { global $db; switch ($action) { - case 'dashboard': self::handleDashboard($rUserInfo, $rPermissions, $db); break; - case 'connections': self::handleConnections($rUserInfo, $rPermissions, $db); break; - case 'line': self::handleLine($rUserInfo, $rPermissions, $db); break; - case 'line_activity': self::handleLineActivity($rUserInfo, $rPermissions, $db); break; - case 'adjust_credits': self::handleAdjustCredits($rUserInfo, $rPermissions, $db); break; - case 'reg_user': self::handleRegUser($rUserInfo, $rPermissions, $db); break; - case 'ticket': self::handleTicket($rUserInfo, $rPermissions, $db); break; - case 'mag': self::handleMag($rUserInfo, $rPermissions, $db); break; - case 'enigma': self::handleEnigma($rUserInfo, $rPermissions, $db); break; - case 'get_package': self::handleGetPackage($rUserInfo, $rPermissions, $db); break; - case 'get_package_trial': self::handleGetPackageTrial($rUserInfo, $rPermissions, $db); break; - case 'header_stats': self::handleHeaderStats($rUserInfo, $rPermissions, $db); break; - case 'stats': self::handleStats($rUserInfo, $rPermissions, $db); break; - case 'userlist': self::handleUserList($rUserInfo, $rPermissions, $db); break; - case 'send_event': self::handleSendEvent($rUserInfo, $rPermissions, $db); break; - case 'streamlist': self::handleStreamList($rUserInfo, $rPermissions, $db); break; - case 'ip_whois': self::handleIpWhois($rUserInfo, $rPermissions, $db); break; - case 'get_epg': self::handleGetEpg($rUserInfo, $rPermissions, $db); break; - case 'get_programme': self::handleGetProgramme($rUserInfo, $rPermissions, $db); break; + case 'dashboard': + self::handleDashboard($rUserInfo, $rPermissions, $db); + break; + case 'connections': + self::handleConnections($rUserInfo, $rPermissions, $db); + break; + case 'line': + self::handleLine($rUserInfo, $rPermissions, $db); + break; + case 'line_activity': + self::handleLineActivity($rUserInfo, $rPermissions, $db); + break; + case 'adjust_credits': + self::handleAdjustCredits($rUserInfo, $rPermissions, $db); + break; + case 'reg_user': + self::handleRegUser($rUserInfo, $rPermissions, $db); + break; + case 'ticket': + self::handleTicket($rUserInfo, $rPermissions, $db); + break; + case 'mag': + self::handleMag($rUserInfo, $rPermissions, $db); + break; + case 'enigma': + self::handleEnigma($rUserInfo, $rPermissions, $db); + break; + case 'get_package': + self::handleGetPackage($rUserInfo, $rPermissions, $db); + break; + case 'get_package_trial': + self::handleGetPackageTrial($rUserInfo, $rPermissions, $db); + break; + case 'header_stats': + self::handleHeaderStats($rUserInfo, $rPermissions, $db); + break; + case 'stats': + self::handleStats($rUserInfo, $rPermissions, $db); + break; + case 'userlist': + self::handleUserList($rUserInfo, $rPermissions, $db); + break; + case 'send_event': + self::handleSendEvent($rUserInfo, $rPermissions, $db); + break; + case 'streamlist': + self::handleStreamList($rUserInfo, $rPermissions, $db); + break; + case 'ip_whois': + self::handleIpWhois($rUserInfo, $rPermissions, $db); + break; + case 'get_epg': + self::handleGetEpg($rUserInfo, $rPermissions, $db); + break; + case 'get_programme': + self::handleGetProgramme($rUserInfo, $rPermissions, $db); + break; + case 'active_code_details': + self::handleActiveCodeDetails($rUserInfo, $rPermissions, $db); + break; + case 'active_codes_mass': + self::handleActiveCodesMass($rUserInfo, $rPermissions, $db); + break; + case 'active_codes_batch_action': + self::handleActiveCodesBatchAction($rUserInfo, $rPermissions, $db); + break; + case 'active_codes_export_txt': + self::handleActiveCodesExportTxt($rUserInfo, $rPermissions, $db); + break; + case 'generate_active_codes': + self::handleGenerateActiveCodes($rUserInfo, $rPermissions, $db); + break; } } @@ -1014,4 +1069,173 @@ class ResellerApiDispatcher { } exit(); } + + /** + * Handle Active Code Details AJAX (modal view) + */ + private static function handleActiveCodeDetails(?array $rUserInfo, array $rPermissions, $db): void { + $codeId = intval(RequestManager::get('id') ?? 0); + if (!$codeId) { + echo json_encode(['result' => false, 'message' => 'Missing code ID.']); + exit(); + } + + $allowedReports = (array)($rUserInfo['reports'] ?? [$rUserInfo['id']]); + $code = $db->fetchOne( + "SELECT `activation_codes`.*, `lines`.`username` as `sub_username`, `lines`.`password` as `sub_password`, + `lines`.`exp_date` as `sub_exp_date`, `lines`.`max_connections` as `line_max_conn` + FROM `activation_codes` + LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` + WHERE `activation_codes`.`id` = ? AND `activation_codes`.`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ") LIMIT 1;", + $codeId + ); + + if (!$code) { + echo json_encode(['result' => false, 'message' => 'Code not found or access denied.']); + exit(); + } + + $package = PackageService::getById((int)$code['package_id']); + $portalUrl = rtrim($code['dns_base'] ?: DomainResolver::resolve(SERVER_ID), '/'); + $portalParsed = parse_url($portalUrl); + + $m3uHls = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=hls"; + $m3uTs = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=ts"; + + $portalCode = null; + $db->query("SELECT `code` FROM `access_codes` WHERE `type` = 7 AND `enabled` = 1 LIMIT 1;"); + if ($db->num_rows() > 0) { + $portalCode = $db->get_row()['code']; + } + + $playerCode = null; + $db->query("SELECT `code` FROM `access_codes` WHERE `type` = 6 AND `enabled` = 1 LIMIT 1;"); + if ($db->num_rows() > 0) { + $playerCode = $db->get_row()['code']; + } + + $subscriberPortalUrl = $portalCode ? "{$portalUrl}/{$portalCode}/" : "{$portalUrl}/portal"; + $directActivateUrl = "{$subscriberPortalUrl}?code=" . urlencode((string)$code['activation_code']); + $webPlayerUrl = $playerCode ? "{$portalUrl}/{$playerCode}/" : null; + + echo json_encode([ + 'result' => true, + 'data' => [ + 'id' => (int)$code['id'], + 'code' => $code['activation_code'], + 'batch_name' => $code['batch_name'], + 'status' => (int)$code['status'], + 'status_text' => ($code['status'] == 1) ? 'Ready (Stock)' : (($code['status'] == 2) ? 'Active' : 'Disabled'), + 'package_name' => $package['package_name'] ?? 'Custom Package', + 'is_trial' => (bool)$code['is_trial'], + 'max_connections' => (int)($code['line_max_conn'] ?: $code['max_connections']), + 'exp_date' => $code['sub_exp_date'] ? date('Y-m-d H:i:s', (int)$code['sub_exp_date']) : 'Frozen (Stock)', + 'activated_at' => $code['activated_at'] ? date('Y-m-d H:i:s', (int)$code['activated_at']) : 'Never', + 'created_at' => $code['created_at'] ? date('Y-m-d H:i:s', (int)$code['created_at']) : '-', + 'mac' => $code['mac'] ?: 'None', + 'device_id' => $code['device_id'] ?: 'None', + 'username' => $code['sub_username'], + 'password' => $code['sub_password'], + 'server' => $portalParsed['host'] ?? 'localhost', + 'port' => $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80), + 'portal_url' => $portalUrl, + 'activation_portal_url' => $subscriberPortalUrl, + 'direct_activate_url' => $directActivateUrl, + 'web_player_url' => $webPlayerUrl, + 'm3u_hls' => $m3uHls, + 'm3u_ts' => $m3uTs, + ] + ]); + exit(); + } + + /** + * Handle Active Codes Mass Actions AJAX + */ + private static function handleActiveCodesMass(?array $rUserInfo, array $rPermissions, $db): void { + $subAction = trim(RequestManager::get('sub_action') ?? ''); + $ids = json_decode(RequestManager::get('ids') ?? '[]', true) ?: []; + $extra = [ + 'days' => intval(RequestManager::get('days') ?? 30), + 'package_id' => intval(RequestManager::get('package_id') ?? 0), + 'refund_credits' => !empty(RequestManager::get('refund_credits')), + ]; + + $res = ActiveCodeService::massAction($subAction, $ids, $rUserInfo, false, $extra); + echo json_encode([ + 'result' => ($res['status'] === 'SUCCESS'), + 'message' => $res['message'] ?? 'Action processed.' + ]); + exit(); + } + + /** + * Handle Batch Action AJAX (Enable, Disable, Delete) + */ + private static function handleActiveCodesBatchAction(?array $rUserInfo, array $rPermissions, $db): void { + $batchName = trim(RequestManager::get('batch_name') ?? ''); + $subAction = trim(RequestManager::get('sub_action') ?? ''); + $refund = !empty(RequestManager::get('refund_credits')); + + if (empty($batchName)) { + echo json_encode(['result' => false, 'message' => 'Missing batch name.']); + exit(); + } + + $allowedReports = (array)($rUserInfo['reports'] ?? [$rUserInfo['id']]); + $codes = $db->fetchAll( + "SELECT `id` FROM `activation_codes` WHERE `batch_name` = ? AND `created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ");", + $batchName + ); + + if (empty($codes)) { + echo json_encode(['result' => false, 'message' => 'No codes found for this batch.']); + exit(); + } + + $ids = array_column($codes, 'id'); + $res = ActiveCodeService::massAction($subAction, $ids, $rUserInfo, false, ['refund_credits' => $refund]); + + echo json_encode([ + 'result' => ($res['status'] === 'SUCCESS'), + 'message' => $res['message'] ?? 'Batch action processed.' + ]); + exit(); + } + + /** + * Handle Export Scratch Cards TXT + */ + private static function handleActiveCodesExportTxt(?array $rUserInfo, array $rPermissions, $db): void { + $batchName = trim(RequestManager::get('batch_name') ?? ''); + if (empty($batchName)) { + exit('Invalid batch name'); + } + + $content = ActiveCodeService::exportBatchTxt($batchName, $rUserInfo, false); + $filename = preg_replace('/[^A-Za-z0-9_\-]/', '_', $batchName) . '_vouchers.txt'; + + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . strlen($content)); + echo $content; + exit(); + } + + /** + * Handle AJAX Code Generation + */ + private static function handleGenerateActiveCodes(?array $rUserInfo, array $rPermissions, $db): void { + $data = RequestManager::getAll(); + $res = ActiveCodeService::generateCodes($data, $rUserInfo, false); + + echo json_encode([ + 'result' => ($res['status'] === 'SUCCESS'), + 'message' => $res['message'] ?? '', + 'batch_name' => $res['batch_name'] ?? null, + 'qty' => $res['qty'] ?? 0, + 'codes' => $res['codes'] ?? [] + ]); + exit(); + } } diff --git a/src/Infrastructure/ResellerTableRenderer.php b/src/Infrastructure/ResellerTableRenderer.php index 529706fc..814c6d82 100644 --- a/src/Infrastructure/ResellerTableRenderer.php +++ b/src/Infrastructure/ResellerTableRenderer.php @@ -88,6 +88,9 @@ class ResellerTableRenderer { case 'reg_users': self::handleRegUsers($rReturn, $rIsAPI, $rUserInfo, $rPermissions, $rSettings, $db, $rStart, $rLimit); break; + case 'active_codes': + self::handleActiveCodes($rReturn, $rIsAPI, $rUserInfo, $rPermissions, $rSettings, $db, $rStart, $rLimit); + break; } } @@ -125,6 +128,7 @@ class ResellerTableRenderer { } $rWhere = $rWhereV = array(); $rWhere[] = '`lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0'; + $rWhere[] = '(`lines`.`is_activecode` = 0 OR `lines`.`is_activecode` IS NULL)'; $rWhere[] = '`lines`.`member_id` IN (' . implode(',', $rUserInfo['reports']) . ')'; if (0 >= strlen(RequestManager::get('search')['value'])) { } else { @@ -1797,8 +1801,141 @@ class ResellerTableRenderer { } } return $rReturn; - } else { - return $rRow; } + return $rRow; + } + + /** + * Render the reseller "active_codes" table. + */ + private static function handleActiveCodes(array $rReturn, bool $rIsAPI, array $rUserInfo, array $rPermissions, array $rSettings, $db, int $rStart, int $rLimit): void { + $rOrderDirection = (strtolower(RequestManager::get('order')[0]['dir'] ?? '') === 'desc' ? 'desc' : 'asc'); + $rOrder = [ + false, // checkbox + '`activation_codes`.`activation_code`', + '`activation_codes`.`batch_name`', + '`activation_codes`.`package_id`', + '`activation_codes`.`status`', + '`lines`.`exp_date`', + '`lines`.`username`', + '`activation_codes`.`mac`', + '`activation_codes`.`created_at`', + false // actions + ]; + + $rOrderRow = (RequestManager::has('order') && strlen(RequestManager::get('order')[0]['column'] ?? '') > 0) + ? intval(RequestManager::get('order')[0]['column']) + : 8; + + $rOrderBy = (isset($rOrder[$rOrderRow]) && $rOrder[$rOrderRow] !== false) + ? "ORDER BY {$rOrder[$rOrderRow]} {$rOrderDirection}" + : "ORDER BY `activation_codes`.`created_at` DESC"; + + $rWhere = []; + $rWhereV = []; + + // Scoped to reseller and sub-resellers + $rWhere[] = '`activation_codes`.`created_by` IN (' . implode(',', array_map('intval', $rUserInfo['reports'])) . ')'; + + // Search + $searchVal = trim(RequestManager::get('search')['value'] ?? ''); + if (strlen($searchVal) > 0) { + $searchParam = "%{$searchVal}%"; + $rWhere[] = '(`activation_codes`.`activation_code` LIKE ? OR `activation_codes`.`batch_name` LIKE ? OR `lines`.`username` LIKE ? OR `activation_codes`.`mac` LIKE ?)'; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + } + + // Status filter: 1=Ready/Stock, 2=Active, 3=Expired, 4=Disabled + $filter = RequestManager::get('filter'); + if (strlen((string)$filter) > 0 && $filter != 0) { + if ($filter == 1) { + $rWhere[] = '`activation_codes`.`status` = 1'; + } elseif ($filter == 2) { + $rWhere[] = '`activation_codes`.`status` = 2 AND (`lines`.`exp_date` IS NULL OR `lines`.`exp_date` > UNIX_TIMESTAMP())'; + } elseif ($filter == 3) { + $rWhere[] = '`activation_codes`.`status` = 2 AND `lines`.`exp_date` IS NOT NULL AND `lines`.`exp_date` <= UNIX_TIMESTAMP()'; + } elseif ($filter == 4) { + $rWhere[] = '`activation_codes`.`status` = 0'; + } + } + + // Batch filter + $batchFilter = trim((string)RequestManager::get('batch')); + if (strlen($batchFilter) > 0) { + $rWhere[] = '`activation_codes`.`batch_name` = ?'; + $rWhereV[] = $batchFilter; + } + + // Package filter + $packageFilter = intval(RequestManager::get('package')); + if ($packageFilter > 0) { + $rWhere[] = '`activation_codes`.`package_id` = ?'; + $rWhereV[] = $packageFilter; + } + + $whereClause = 'WHERE ' . implode(' AND ', $rWhere); + + $countSql = "SELECT COUNT(*) as `total` FROM `activation_codes` LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` {$whereClause};"; + $db->query($countSql, ...$rWhereV); + $rReturn['recordsTotal'] = $rReturn['recordsFiltered'] = (int)($db->get_row()['total'] ?? 0); + + $sql = "SELECT + `activation_codes`.*, + `lines`.`username` as `sub_username`, + `lines`.`exp_date` as `sub_exp_date`, + `lines`.`enabled` as `line_enabled`, + `users`.`username` as `creator_username` + FROM `activation_codes` + LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` + LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by` + {$whereClause} + {$rOrderBy} + LIMIT {$rStart}, {$rLimit};"; + + $db->query($sql, ...$rWhereV); + $rows = $db->get_rows() ?: []; + + $data = []; + $packagesCache = []; + $now = time(); + + foreach ($rows as $row) { + $pkgId = (int) $row['package_id']; + if (!isset($packagesCache[$pkgId])) { + $pkg = PackageService::getById($pkgId); + $packagesCache[$pkgId] = $pkg['package_name'] ?? 'Package #' . $pkgId; + } + + $status = (int) $row['status']; + $expUnix = $row['sub_exp_date'] ? (int) $row['sub_exp_date'] : 0; + $expExpired = ($status === 2 && $expUnix && $expUnix < $now); + $createdUnix = $row['created_at'] ? (int) $row['created_at'] : 0; + + // Clean, keyed row payload; the Bootstrap 5 view renders every badge / + // status / action button client-side. Mirrors the admin active_codes + // handler. The subscriber password is intentionally NOT exposed here. + $data[] = [ + 'id' => (int) $row['id'], + 'code' => (string) $row['activation_code'], + 'batch' => (string) ($row['batch_name'] ?: 'None'), + 'package_name' => $packagesCache[$pkgId], + 'is_trial' => !empty($row['is_trial']), + 'status' => $status, + 'exp_unix' => $expUnix, + 'exp_str' => $expUnix ? date('Y-m-d H:i', $expUnix) : '', + 'exp_expired' => $expExpired, + 'remaining_days' => ($expUnix && !$expExpired && $status !== 0 && $status !== 1) ? (int) ceil(($expUnix - $now) / 86400) : 0, + 'sub_username' => $row['sub_username'] !== null ? (string) $row['sub_username'] : null, + 'mac' => !empty($row['mac']) ? (string) $row['mac'] : null, + 'created_str' => $createdUnix ? date('Y-m-d H:i', $createdUnix) : '-', + ]; + } + + $rReturn['data'] = $data; + echo json_encode($rReturn); + exit(); } } diff --git a/src/Public/Controllers/Admin/ActiveCodeController.php b/src/Public/Controllers/Admin/ActiveCodeController.php new file mode 100644 index 00000000..34702b57 --- /dev/null +++ b/src/Public/Controllers/Admin/ActiveCodeController.php @@ -0,0 +1,27 @@ +requirePermission(); + $this->setTitle('Generate Active Codes'); + + $this->render('active_code', [ + 'rPackages' => PackageService::getAll(1, 'line') ?: [], + 'rBouquets' => BouquetService::getAll() ?: [], + 'rResellers' => ActiveCodeService::getResellersForAssignment(), + ]); + } +} diff --git a/src/Public/Controllers/Admin/ActiveCodesBatchController.php b/src/Public/Controllers/Admin/ActiveCodesBatchController.php new file mode 100644 index 00000000..1c7eff97 --- /dev/null +++ b/src/Public/Controllers/Admin/ActiveCodesBatchController.php @@ -0,0 +1,25 @@ +requirePermission(); + $this->setTitle('Active Codes Batch Manager'); + + $batches = ActiveCodeService::getBatchSummary([], true); + + $this->render('active_codes_batch', [ + 'batches' => $batches, + ]); + } +} diff --git a/src/Public/Controllers/Admin/ActiveCodesController.php b/src/Public/Controllers/Admin/ActiveCodesController.php new file mode 100644 index 00000000..1b53abee --- /dev/null +++ b/src/Public/Controllers/Admin/ActiveCodesController.php @@ -0,0 +1,25 @@ +requirePermission(); + $this->setTitle('Active Codes'); + $this->render('active_codes', [ + 'rPackages' => PackageService::getAll(1, 'line') ?: [], + 'resellers' => ActiveCodeService::getResellersWithCodes(), + 'batches' => ActiveCodeService::getRecentBatchNames(), + ]); + } +} diff --git a/src/Public/Controllers/Admin/ActiveCodesMassController.php b/src/Public/Controllers/Admin/ActiveCodesMassController.php new file mode 100644 index 00000000..2a2a24b5 --- /dev/null +++ b/src/Public/Controllers/Admin/ActiveCodesMassController.php @@ -0,0 +1,26 @@ +requirePermission(); + $this->setTitle('Mass Edit Active Codes'); + + $this->render('active_codes_mass', [ + 'rPackages' => PackageService::getAll(1, 'line') ?: [], + 'batches' => ActiveCodeService::getRecentBatchNames(), + 'resellers' => ActiveCodeService::getResellersWithCodes(), + ]); + } +} diff --git a/src/Public/Controllers/Admin/Ajax/ActiveCodeAjaxController.php b/src/Public/Controllers/Admin/Ajax/ActiveCodeAjaxController.php new file mode 100644 index 00000000..cead03f1 --- /dev/null +++ b/src/Public/Controllers/Admin/Ajax/ActiveCodeAjaxController.php @@ -0,0 +1,174 @@ +requireXhr(); + + global $db; + $codeId = intval(RequestManager::get('id') ?? 0); + if (!$codeId) { + $this->fail(['message' => 'Missing code ID.']); + } + + $code = $db->fetchOne( + "SELECT `activation_codes`.*, `lines`.`username` as `sub_username`, `lines`.`password` as `sub_password`, + `lines`.`exp_date` as `sub_exp_date`, `lines`.`max_connections` as `line_max_conn` + FROM `activation_codes` + LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` + WHERE `activation_codes`.`id` = ? LIMIT 1;", + $codeId + ); + + if (!$code) { + $this->fail(['message' => 'Activation code not found.']); + } + + $package = PackageService::getById((int)$code['package_id']); + $portalUrl = rtrim($code['dns_base'] ?: DomainResolver::resolve(SERVER_ID), '/'); + $portalParsed = parse_url($portalUrl); + + $m3uHls = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=hls"; + $m3uTs = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=ts"; + + $portalCode = AuthRepository::getActiveCodePortalCode(); + $playerCode = AuthRepository::getWebPlayerCode(); + + $subscriberPortalUrl = $portalCode ? "{$portalUrl}/{$portalCode}/" : "{$portalUrl}/portal"; + $directActivateUrl = "{$subscriberPortalUrl}?code=" . urlencode((string)$code['activation_code']); + $webPlayerUrl = $playerCode ? "{$portalUrl}/{$playerCode}/" : null; + + $this->ok([ + 'data' => [ + 'id' => (int)$code['id'], + 'code' => $code['activation_code'], + 'batch_name' => $code['batch_name'], + 'status' => (int)$code['status'], + 'status_text' => ($code['status'] == 1) ? 'Ready (Stock)' : (($code['status'] == 2) ? 'Active' : 'Disabled'), + 'package_name' => $package['package_name'] ?? 'Custom Package', + 'is_trial' => (bool)$code['is_trial'], + 'max_connections' => (int)($code['line_max_conn'] ?: $code['max_connections']), + 'exp_date' => $code['sub_exp_date'] ? date('Y-m-d H:i:s', (int)$code['sub_exp_date']) : 'Frozen (Stock)', + 'activated_at' => $code['activated_at'] ? date('Y-m-d H:i:s', (int)$code['activated_at']) : 'Never', + 'created_at' => $code['created_at'] ? date('Y-m-d H:i:s', (int)$code['created_at']) : '-', + 'mac' => $code['mac'] ?: 'None', + 'device_id' => $code['device_id'] ?: 'None', + 'username' => $code['sub_username'], + 'password' => $code['sub_password'], + 'server' => $portalParsed['host'] ?? 'localhost', + 'port' => $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80), + 'portal_url' => $portalUrl, + 'activation_portal_url' => $subscriberPortalUrl, + 'direct_activate_url' => $directActivateUrl, + 'web_player_url' => $webPlayerUrl, + 'm3u_hls' => $m3uHls, + 'm3u_ts' => $m3uTs, + ] + ]); + } + + /** + * action=generate_active_codes — Generate active codes batch (Admin). + */ + public function generate(): never + { + $this->requireXhr(); + + $data = RequestManager::getAll(); + $user = $GLOBALS['rUserInfo'] ?? []; + $res = ActiveCodeService::generateCodes($data, $user, true); + + if ($res['status'] === 'SUCCESS') { + $this->ok([ + 'message' => $res['message'] ?? '', + 'batch_name' => $res['batch_name'] ?? null, + 'qty' => $res['qty'] ?? 0, + 'codes' => $res['codes'] ?? [] + ]); + } + + $this->fail([ + 'message' => $res['message'] ?? 'Failed to generate codes.' + ]); + } + + /** + * action=active_codes_batch_action — Batch enable/disable/delete (Admin). + */ + public function batchAction(): never + { + $this->requireXhr(); + + global $db; + $batchName = trim(RequestManager::get('batch_name') ?? ''); + $subAction = trim(RequestManager::get('sub_action') ?? ''); + $refund = !empty(RequestManager::get('refund_credits')); + + if (empty($batchName)) { + $this->fail(['message' => 'Missing batch name.']); + } + + $codes = $db->fetchAll( + "SELECT `id` FROM `activation_codes` WHERE `batch_name` = ?;", + $batchName + ); + + if (empty($codes)) { + $this->fail(['message' => 'No codes found for this batch.']); + } + + $ids = array_column($codes, 'id'); + $user = $GLOBALS['rUserInfo'] ?? []; + $res = ActiveCodeService::massAction($subAction, $ids, $user, true, ['refund_credits' => $refund]); + + if ($res['status'] === 'SUCCESS') { + $this->ok(['message' => $res['message'] ?? 'Batch action processed.']); + } + + $this->fail(['message' => $res['message'] ?? 'Batch action failed.']); + } + + /** + * action=active_codes_export_txt — Export physical scratch card vouchers as .txt. + */ + public function exportTxt(): never + { + $batchName = trim(RequestManager::get('batch_name') ?? ''); + if (empty($batchName)) { + exit('Invalid batch name'); + } + + $user = $GLOBALS['rUserInfo'] ?? []; + $content = ActiveCodeService::exportBatchTxt($batchName, $user, true); + $filename = preg_replace('/[^A-Za-z0-9_\-]/', '_', $batchName) . '_vouchers.txt'; + + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . strlen($content)); + echo $content; + exit(); + } +} diff --git a/src/Public/Controllers/Admin/Ajax/MultiAjaxController.php b/src/Public/Controllers/Admin/Ajax/MultiAjaxController.php index 9efdbea2..6e3ea449 100644 --- a/src/Public/Controllers/Admin/Ajax/MultiAjaxController.php +++ b/src/Public/Controllers/Admin/Ajax/MultiAjaxController.php @@ -7,6 +7,7 @@ use XcVm\Core\Http\ApiClient; use XcVm\Core\Http\RequestManager; use XcVm\Domain\Device\EnigmaService; use XcVm\Domain\Device\MagService; +use XcVm\Domain\Line\ActiveCodeService; use XcVm\Domain\Line\LineRepository; use XcVm\Domain\Line\LineService; use XcVm\Domain\Server\ServerRepository; @@ -47,28 +48,24 @@ class MultiAjaxController extends BaseAjaxController { switch ($rType) { case 'line': $this->handleLine($rRequestIDs, $rSub); - // no break — handler terminates the request + case 'active_code': + $this->handleActiveCode($rRequestIDs, $rSub); case 'mag': case 'enigma': $this->handleDevices($rType, $rRequestIDs, $rSub); - // no break case 'user': $this->handleUser($rRequestIDs, $rSub); - // no break case 'server': case 'proxy': $this->handleServers($rType, $rRequestIDs, $rSub); - // no break case 'series': $this->handleSeries($rRequestIDs, $rSub); - // no break case 'stream': case 'movie': case 'episode': case 'cchannel': case 'radio': $this->handleStreams($rType, $rRequestIDs, $rSub); - // no break } } @@ -357,4 +354,15 @@ class MultiAjaxController extends BaseAjaxController { private function inList(array $rIDs): string { return implode(',', array_map('intval', $rIDs)); } + + private function handleActiveCode(array $rRequestIDs, string $rSub): never { + $extra = [ + 'days' => intval(RequestManager::get('days') ?? 30), + 'package_id' => intval(RequestManager::get('package_id') ?? 0), + 'refund_credits' => !empty(RequestManager::get('refund_credits')), + ]; + $res = ActiveCodeService::massAction($rSub, $rRequestIDs, $GLOBALS['rUserInfo'] ?? [], true, $extra); + echo json_encode(['result' => ($res['status'] === 'SUCCESS'), 'message' => $res['message'] ?? '']); + exit; + } } diff --git a/src/Public/Controllers/Admin/TableController.php b/src/Public/Controllers/Admin/TableController.php index 7c255ff2..5c35f0a1 100644 --- a/src/Public/Controllers/Admin/TableController.php +++ b/src/Public/Controllers/Admin/TableController.php @@ -104,6 +104,9 @@ class TableController extends BaseAdminController { case "lines": $this->handleLines($rReturn, $rStart, $rLimit, $rIsAPI); return; + case "active_codes": + $this->handleActiveCodes($rReturn, $rStart, $rLimit, $rIsAPI); + return; case "mags": $this->handleMags($rReturn, $rStart, $rLimit, $rIsAPI); return; @@ -243,6 +246,150 @@ class TableController extends BaseAdminController { } } + private function handleActiveCodes($rReturn, $rStart, $rLimit, $rIsAPI) { + global $db; + if (!Authorization::check("adv", "users") && !Authorization::check("adv", "manage_lines")) { + exit; + } + + $rOrderDirection = strtolower(RequestManager::get("order")[0]["dir"] ?? '') === "desc" ? "desc" : "asc"; + $rOrder = [ + false, // control + false, // checkbox + '`activation_codes`.`activation_code`', + '`activation_codes`.`batch_name`', + '`activation_codes`.`package_id`', + '`users`.`username`', + '`activation_codes`.`status`', + '`lines`.`exp_date`', + '`lines`.`username`', + '`activation_codes`.`mac`', + '`activation_codes`.`created_at`', + false // actions + ]; + + $rOrderRow = (RequestManager::has("order") && strlen(RequestManager::get("order")[0]["column"] ?? '') > 0) + ? (int)(RequestManager::get("order")[0]["column"]) + : 10; + + $rOrderBy = (isset($rOrder[$rOrderRow]) && $rOrder[$rOrderRow] !== false) + ? "ORDER BY {$rOrder[$rOrderRow]} {$rOrderDirection}" + : "ORDER BY `activation_codes`.`created_at` DESC"; + + $rWhere = []; + $rWhereV = []; + + // Reseller filter + $resellerFilter = (int)(RequestManager::get("reseller") ?? 0); + if ($resellerFilter > 0) { + $rWhere[] = "`activation_codes`.`created_by` = ?"; + $rWhereV[] = $resellerFilter; + } + + // Status filter: 1=Ready/Stock, 2=Active, 3=Expired, 4=Disabled + $filter = RequestManager::get("filter"); + if (strlen((string)$filter) > 0 && $filter != 0) { + if ($filter == 1) { + $rWhere[] = "`activation_codes`.`status` = 1"; + } elseif ($filter == 2) { + $rWhere[] = "`activation_codes`.`status` = 2 AND (`lines`.`exp_date` IS NULL OR `lines`.`exp_date` > UNIX_TIMESTAMP())"; + } elseif ($filter == 3) { + $rWhere[] = "`activation_codes`.`status` = 2 AND `lines`.`exp_date` IS NOT NULL AND `lines`.`exp_date` <= UNIX_TIMESTAMP()"; + } elseif ($filter == 4) { + $rWhere[] = "`activation_codes`.`status` = 0"; + } + } + + // Batch filter + $batchFilter = trim((string)RequestManager::get("batch")); + if (strlen($batchFilter) > 0) { + $rWhere[] = "`activation_codes`.`batch_name` = ?"; + $rWhereV[] = $batchFilter; + } + + // Package filter + $packageFilter = (int)(RequestManager::get("package") ?? 0); + if ($packageFilter > 0) { + $rWhere[] = "`activation_codes`.`package_id` = ?"; + $rWhereV[] = $packageFilter; + } + + // Search + $searchVal = trim(RequestManager::get("search")["value"] ?? ''); + if (strlen($searchVal) > 0) { + $searchParam = "%{$searchVal}%"; + $rWhere[] = "(`activation_codes`.`activation_code` LIKE ? OR `activation_codes`.`batch_name` LIKE ? OR `lines`.`username` LIKE ? OR `users`.`username` LIKE ? OR `activation_codes`.`mac` LIKE ?)"; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + $rWhereV[] = $searchParam; + } + + $whereClause = !empty($rWhere) ? ("WHERE " . implode(" AND ", $rWhere)) : ""; + + $countSql = "SELECT COUNT(*) as `total` FROM `activation_codes` LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by` {$whereClause};"; + $db->query($countSql, ...$rWhereV); + $rReturn["recordsTotal"] = $rReturn["recordsFiltered"] = (int)($db->get_row()["total"] ?? 0); + + $sql = "SELECT + `activation_codes`.*, + `lines`.`username` as `sub_username`, + `lines`.`exp_date` as `sub_exp_date`, + `lines`.`enabled` as `line_enabled`, + `users`.`username` as `creator_username` + FROM `activation_codes` + LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` + LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by` + {$whereClause} + {$rOrderBy} + LIMIT {$rStart}, {$rLimit};"; + + $db->query($sql, ...$rWhereV); + $rows = $db->get_rows() ?: []; + + $data = []; + $packagesCache = []; + $now = time(); + + foreach ($rows as $row) { + $pkgId = (int) $row["package_id"]; + if (!isset($packagesCache[$pkgId])) { + $pkg = PackageService::getById($pkgId); + $packagesCache[$pkgId] = $pkg["package_name"] ?? "Package #" . $pkgId; + } + + $status = (int) $row["status"]; + $expUnix = $row["sub_exp_date"] ? (int) $row["sub_exp_date"] : 0; + $expExpired = ($status === 2 && $expUnix && $expUnix < $now); + $createdUnix = $row["created_at"] ? (int) $row["created_at"] : 0; + + // Clean, keyed row payload; the Bootstrap 5 view renders every badge / + // status / action button client-side. Mirrors the reseller active_codes + // handler. The subscriber password is intentionally NOT exposed here. + $data[] = [ + "id" => (int) $row["id"], + "code" => (string) $row["activation_code"], + "batch" => (string) ($row["batch_name"] ?: "None"), + "package_name" => $packagesCache[$pkgId], + "is_trial" => !empty($row["is_trial"]), + "creator" => (string) ($row["creator_username"] ?: "Admin"), + "status" => $status, + "exp_unix" => $expUnix, + "exp_str" => $expUnix ? date("Y-m-d H:i", $expUnix) : "", + "exp_expired" => $expExpired, + "remaining_days" => ($expUnix && !$expExpired && $status !== 0 && $status !== 1) ? (int) ceil(($expUnix - $now) / 86400) : 0, + "sub_username" => $row["sub_username"] !== null ? (string) $row["sub_username"] : null, + "mac" => !empty($row["mac"]) ? (string) $row["mac"] : null, + "created_str" => $createdUnix ? date("Y-m-d H:i", $createdUnix) : "-", + ]; + } + + $rReturn["data"] = $data; + echo json_encode($rReturn); + exit; + } + private function handleLines($rReturn, $rStart, $rLimit, $rIsAPI) { global $db, $rSettings; if (!Authorization::check("adv", "users") && !Authorization::check("adv", "mass_edit_users")) { @@ -258,6 +405,7 @@ class TableController extends BaseAdminController { } $rWhere = $rWhereV = []; $rWhere[] = "(`is_mag` + `is_e2`) = 0"; + $rWhere[] = "(`lines`.`is_activecode` = 0 OR `lines`.`is_activecode` IS NULL)"; if (0 < strlen(RequestManager::get("search")["value"] ?? '')) { foreach (range(1, 6) as $rInt) { $rWhereV[] = "%" . RequestManager::get("search")["value"] . "%"; @@ -769,10 +917,7 @@ class TableController extends BaseAdminController { } $rCategories = CategoryService::getAllByType("live"); // Leading false, false = Responsive control + bulk-select checkbox columns (Bootstrap 5). - // One entry per column of the streams table (admin/streams.php), in order: - // control, select, id, icon, title, server, connections, status, player, - // EPG, stream info, usage, actions. false = not sortable in SQL. - $rOrder = [false, false, "`streams`.`id`", "`streams`.`stream_icon`", "`streams`.`stream_display_name`", "`streams_servers`.`current_source`", "`clients`", "`streams_servers`.`stream_started`", false, false, false, false, "`streams_servers`.`bitrate`"]; + $rOrder = [false, false, "`streams`.`id`", "`streams`.`stream_icon`", "`streams`.`stream_display_name`", "`streams_servers`.`current_source`", "`clients`", "`streams_servers`.`stream_started`", false, false, false, "`streams_servers`.`bitrate`"]; if (RequestManager::has("order") && 0 < strlen(RequestManager::get("order")[0]["column"] ?? '')) { $rOrderRow = (int) (RequestManager::get("order")[0]["column"] ?? 0); } else { @@ -1145,21 +1290,6 @@ class TableController extends BaseAdminController { $rPlayerVideo = strtoupper((string) ($rVideo["codec_name"] ?? "")); } - // What the producer costs this node, and which producer it is - // (ffmpeg, or the fanout daemon's native remuxer). Sampled - // from /proc by cron:streams ON the server that runs the - // stream — only it can read its own processes — and carried - // here in the progress report it writes anyway. - $rUsage = null; - $rUsageInfo = json_decode($rRow["progress_info"] ?? '', true); - if (is_array($rUsageInfo) && (isset($rUsageInfo["mem"]) || isset($rUsageInfo["producer"]))) { - $rUsage = [ - "cpu" => isset($rUsageInfo["cpu"]) ? (float) $rUsageInfo["cpu"] : null, - "mem" => isset($rUsageInfo["mem"]) ? (int) $rUsageInfo["mem"] : null, - "producer" => $rUsageInfo["producer"] ?? null, - ]; - } - // EPG availability + player codec compatibility. $rEPG = file_exists(EPG_PATH . "stream_" . $rRow["id"]) ? "available" : ($rRow["channel_id"] ? "pending" : "none"); $rPlayerOk = false; @@ -1194,7 +1324,6 @@ class TableController extends BaseAdminController { "notes" => !empty($rRow["notes"]) ? $rRow["notes"] : null, "player_ok" => $rPlayerOk, "info" => $rInfo, - "usage" => $rUsage, ]; } } diff --git a/src/Public/Controllers/Api/ActiveCodeApiController.php b/src/Public/Controllers/Api/ActiveCodeApiController.php new file mode 100644 index 00000000..4f57ab8d --- /dev/null +++ b/src/Public/Controllers/Api/ActiveCodeApiController.php @@ -0,0 +1,169 @@ +deny = true; + http_response_code(400); + echo json_encode([ + 'status' => 'ERROR', + 'user_info' => ['auth' => 0], + 'message' => 'Missing activation code. Please provide "code" or "activation_code".' + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit(); + } + + $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; + $deviceInfo = [ + 'mac' => $mac, + 'device_id' => $deviceId, + 'ip' => $clientIp, + ]; + + // Process activation / stock-countdown trigger + $res = ActiveCodeService::activateCode($code, $deviceInfo); + + if ($res['status'] !== 'SUCCESS') { + $this->deny = true; + BruteforceGuard::checkBruteforce(null, null, $code); + http_response_code(200); + echo json_encode([ + 'status' => 'ERROR', + 'error_code' => $res['status'], + 'user_info' => ['auth' => 0], + 'message' => $res['message'] ?? 'Activation failed.' + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit(); + } + + $this->deny = false; + + $username = $res['line']['username'] ?? $res['credentials']['username'] ?? ''; + $password = $res['line']['password'] ?? $res['credentials']['password'] ?? ''; + $maxConnections = strval($res['line']['max_connections'] ?? $res['max_connections'] ?? '1'); + $codeDetails = $res['code_details'] ?? []; + + // Build standard server_info + $serverId = defined('SERVER_ID') ? SERVER_ID : 1; + $domainName = DomainResolver::resolve($serverId); + $domain = parse_url($domainName, PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $protocol = $rServers[$serverId]['server_protocol'] ?? 'http'; + $port = strval($rServers[$serverId]['http_broadcast_port'] ?? 80); + $httpsPort = strval($rServers[$serverId]['https_broadcast_port'] ?? 443); + $rtmpPort = strval($rServers[$serverId]['rtmp_port'] ?? 25462); + + $serverUrl = $protocol . '://' . $domain . ($port != '80' && $port != '443' ? ':' . $port : ''); + if (!empty($codeDetails['dns_base'])) { + $serverUrl = rtrim($codeDetails['dns_base'], '/'); + } + + $m3uTs = $serverUrl . '/get.php?username=' . urlencode($username) . '&password=' . urlencode($password) . '&type=m3u_plus&output=ts'; + $m3uHls = $serverUrl . '/get.php?username=' . urlencode($username) . '&password=' . urlencode($password) . '&type=m3u_plus&output=m3u8'; + + $output = [ + 'status' => 'SUCCESS', + 'user_info' => [ + 'username' => $username, + 'password' => $password, + 'message' => $rSettings['message_of_day'] ?? 'Welcome to IPTV', + 'auth' => 1, + 'status' => 'Active', + 'exp_date' => !empty($res['exp_date']) ? strval($res['exp_date']) : null, + 'exp_formatted' => $res['exp_formatted'] ?? ($res['exp_date_formatted'] ?? ''), + 'is_trial' => strval($codeDetails['is_trial'] ?? '0'), + 'active_cons' => '0', + 'created_at' => strval($codeDetails['created_at'] ?? time()), + 'max_connections' => $maxConnections, + 'allowed_output_formats' => ['m3u8', 'ts', 'rtmp'] + ], + 'server_info' => [ + 'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : '1.0.0', + 'url' => $domain, + 'port' => $port, + 'https_port' => $httpsPort, + 'server_protocol' => $protocol, + 'rtmp_port' => $rtmpPort, + 'timestamp_now' => time(), + 'time_now' => date('Y-m-d H:i:s'), + 'timezone' => ($rSettings['force_epg_timezone'] ?? false) ? 'UTC' : ($rSettings['default_timezone'] ?? 'UTC'), + 'process' => true + ], + 'active_code' => [ + 'code' => $res['code'], + 'batch_name' => $codeDetails['batch_name'] ?? '', + 'package_name' => $res['package_name'] ?? 'Premium IPTV', + 'status' => 'Active', + 'is_new_activation' => $res['is_new_activation'] ?? false, + ], + 'playlists' => [ + 'm3u_ts' => $m3uTs, + 'm3u_hls' => $m3uHls, + ], + 'player_api_url' => $serverUrl . '/player_api.php?username=' . urlencode($username) . '&password=' . urlencode($password) + ]; + + echo json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit(); + } +} diff --git a/src/Public/Controllers/Api/PlayerApiController.php b/src/Public/Controllers/Api/PlayerApiController.php index ce571974..bd83b488 100644 --- a/src/Public/Controllers/Api/PlayerApiController.php +++ b/src/Public/Controllers/Api/PlayerApiController.php @@ -6,6 +6,7 @@ use XcVm\Core\Auth\BruteforceGuard; use XcVm\Core\Config\DomainResolver; use XcVm\Core\Util\Encryption; use XcVm\Core\Util\ImageUtils; +use XcVm\Domain\Line\ActiveCodeService; use XcVm\Domain\Stream\CategoryService; use XcVm\Domain\Stream\StreamSorter; use XcVm\Domain\User\UserRepository; @@ -106,15 +107,39 @@ class PlayerApiController { } $rUserInfo = null; - if (isset($rRequest['username']) && isset($rRequest['password'])) { + if (isset($rRequest['username'])) { $rUsername = $rRequest['username']; - $rPassword = $rRequest['password']; + $rPassword = $rRequest['password'] ?? ''; - if (empty($rUsername) || empty($rPassword)) { - generateError('NO_CREDENTIALS'); + if (!empty($rUsername) && !empty($rPassword)) { + $rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rUsername, $rPassword, $rGetChannels); } - $rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rUsername, $rPassword, $rGetChannels); + // Active Code transparent auto-activation fallback + if (!$rUserInfo && !empty($rUsername) && class_exists(ActiveCodeService::class)) { + $candidateCode = trim($rUsername); + $codeRow = ActiveCodeService::getByCode($candidateCode); + if ($codeRow) { + $deviceInfo = [ + 'mac' => $rRequest['mac'] ?? '', + 'device_id' => $rRequest['device_id'] ?? '', + 'ip' => $rIP + ]; + $actRes = ActiveCodeService::activateCode($candidateCode, $deviceInfo); + if ($actRes['status'] === 'SUCCESS' && !empty($actRes['line'])) { + $rUsername = $actRes['line']['username']; + $rPassword = $actRes['line']['password']; + $rUserInfo = UserRepository::getStreamingUserInfo($rSettings, false, $rBouquets, null, $rUsername, $rPassword, $rGetChannels); + if ($rUserInfo && !empty($actRes['line']['exp_date'])) { + $rUserInfo['exp_date'] = $actRes['line']['exp_date']; + } + } + } + } + + if (!$rUserInfo && (empty($rUsername) || empty($rPassword))) { + generateError('NO_CREDENTIALS'); + } } else { if (isset($rRequest['token'])) { $rToken = $rRequest['token']; @@ -124,6 +149,27 @@ class PlayerApiController { } $rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rToken, null, $rGetChannels); + + if (!$rUserInfo && class_exists(ActiveCodeService::class)) { + $candidateCode = trim($rToken); + $codeRow = ActiveCodeService::getByCode($candidateCode); + if ($codeRow) { + $deviceInfo = [ + 'mac' => $rRequest['mac'] ?? '', + 'device_id' => $rRequest['device_id'] ?? '', + 'ip' => $rIP + ]; + $actRes = ActiveCodeService::activateCode($candidateCode, $deviceInfo); + if ($actRes['status'] === 'SUCCESS' && !empty($actRes['line'])) { + $rUsername = $actRes['line']['username']; + $rPassword = $actRes['line']['password']; + $rUserInfo = UserRepository::getStreamingUserInfo($rSettings, false, $rBouquets, null, $rUsername, $rPassword, $rGetChannels); + if ($rUserInfo && !empty($actRes['line']['exp_date'])) { + $rUserInfo['exp_date'] = $actRes['line']['exp_date']; + } + } + } + } } } diff --git a/src/Public/Controllers/Player/PortalController.php b/src/Public/Controllers/Player/PortalController.php new file mode 100644 index 00000000..36f72698 --- /dev/null +++ b/src/Public/Controllers/Player/PortalController.php @@ -0,0 +1,50 @@ + trim(RequestManager::get('mac') ?? ''), + 'device_id' => trim(RequestManager::get('device_id') ?? ''), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', + ]; + $result = ActiveCodeService::activateCode($code, $deviceInfo); + + // If AJAX request, return JSON + if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($result); + exit(); + } + } else { + $result = ['status' => 'ERROR', 'message' => 'Please enter an activation code.']; + if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($result); + exit(); + } + } + } + + require MAIN_HOME . 'Public/Views/portal/index.php'; + exit(); + } +} diff --git a/src/Public/Controllers/Reseller/ResellerActiveCodeController.php b/src/Public/Controllers/Reseller/ResellerActiveCodeController.php new file mode 100644 index 00000000..6b88c412 --- /dev/null +++ b/src/Public/Controllers/Reseller/ResellerActiveCodeController.php @@ -0,0 +1,29 @@ +requirePermission(); + $this->setTitle('Generate Active Codes'); + + $rUserInfo = $GLOBALS['rUserInfo'] ?? []; + $rPackages = PackageService::getAll($rUserInfo['member_group_id'] ?? 0, 'line') ?: []; + $rBouquets = BouquetService::getAll() ?: []; + + $this->render('active_code', [ + 'rPackages' => $rPackages, + 'rBouquets' => $rBouquets, + ]); + } +} diff --git a/src/Public/Controllers/Reseller/ResellerActiveCodesBatchController.php b/src/Public/Controllers/Reseller/ResellerActiveCodesBatchController.php new file mode 100644 index 00000000..5df983d5 --- /dev/null +++ b/src/Public/Controllers/Reseller/ResellerActiveCodesBatchController.php @@ -0,0 +1,26 @@ +requirePermission(); + $this->setTitle('Batch Manager'); + + $rUserInfo = $GLOBALS['rUserInfo'] ?? []; + $batches = ActiveCodeService::getBatchSummary($rUserInfo, false); + + $this->render('active_codes_batch', [ + 'batches' => $batches, + ]); + } +} diff --git a/src/Public/Controllers/Reseller/ResellerActiveCodesController.php b/src/Public/Controllers/Reseller/ResellerActiveCodesController.php new file mode 100644 index 00000000..47af82e0 --- /dev/null +++ b/src/Public/Controllers/Reseller/ResellerActiveCodesController.php @@ -0,0 +1,28 @@ +requirePermission(); + $this->setTitle('Active Codes'); + + $rUserInfo = $GLOBALS['rUserInfo'] ?? []; + $allowedReports = (array) ($rUserInfo['reports'] ?? [$rUserInfo['id'] ?? 0]); + + $this->render('active_codes', [ + 'rPackages' => PackageService::getAll($rUserInfo['member_group_id'] ?? 0, 'line') ?: [], + 'batches' => ActiveCodeService::getRecentBatchNames($allowedReports), + ]); + } +} diff --git a/src/Public/Views/admin/active_code.php b/src/Public/Views/admin/active_code.php new file mode 100644 index 00000000..cbcd5e7e --- /dev/null +++ b/src/Public/Views/admin/active_code.php @@ -0,0 +1,656 @@ + + + + +
+
+
+
+
+
Generate Active Codes (Admin)
+

Admins can provision active vouchers for any reseller or system inventory with 0 credit deduction.

+
+ + Back to Codes + +
+ +
+
+ +
+ + +
Admin generations are exempt from credit costs. Selected reseller will be designated as the creator.
+
+ + +
+
+ +
+ + + +
+
+ +
+ + +
+
+ + +
+
+ +
+ + + + + + +
+
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ +
+
+
+ +
+
+ +
Select streaming content categories for this voucher
+
+
+
+ + / Selected + +
+
+ + +
+ +
+ + + +
+ + +
+ + + + +
+
+ + +
+ + + + 0): + ?> + + +
+ + +
+
+ +
+ +
+ +
+ +
No bouquets found matching your filter.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
Admin Privileges Active
+ Unrestricted System Authority +
+
+ + Root Authority + +
+ +
+ +
+
+
+
+ + Credit Cost +
+
FREE (0.00)
+ No credit deduction +
+
+
+
+
+ + Delayed Timer +
+
On 1st Stream
+ Starts upon activation +
+
+
+
+
+ + Assignment +
+
Any Reseller
+ Direct stock transfer +
+
+
+
+
+ + Stock Protection +
+
Protected
+ Never expires in stock +
+
+
+ + +
+
+ + Live Batch Preview + + 5 Vouchers +
+ +
+ Target Owner: + System Administrator +
+
+ Target Package: + -- Not Selected -- +
+
+ Code Format: + Alphanumeric (10 chars) +
+
+ Total Admin Cost: + 0.00 Credits (Exempt) +
+
+ + +
+ +
+ Admin vouchers bypass all balance checks. Created codes remain in Ready (Stock) status indefinitely until redeemed by end-users in the Activation Portal. +
+
+
+
+
+
+ + +
+
+
Active Codes Generated!
+ +
+
+
+
+ Batch: + • + Count: +
+ Open Batch Manager +
+
+ + + + + + + + + + + +
#Activation CodeUsernamePasswordStatus
+
+
+
+ + + + + + + diff --git a/src/Public/Views/admin/active_codes.php b/src/Public/Views/admin/active_codes.php new file mode 100644 index 00000000..8313bac9 --- /dev/null +++ b/src/Public/Views/admin/active_codes.php @@ -0,0 +1,625 @@ + + +
+
+
+
Active Codes Management
+

System-wide active codes inventory across all resellers with delayed countdown lifecycle.

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + CodeBatchPackageOwner / ResellerStatusExpirationSubscriberDevice LockCreatedActions
+
+
+ + +
+
+ 0 + Codes Selected +
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/Public/Views/admin/active_codes_batch.php b/src/Public/Views/admin/active_codes_batch.php new file mode 100644 index 00000000..8236ec12 --- /dev/null +++ b/src/Public/Views/admin/active_codes_batch.php @@ -0,0 +1,238 @@ + + + +
+
+
+
+
+
Total Batches
+

+
+
+ +
+
+
+
+
+
+
+
+
Total Codes
+

+
+
+ +
+
+
+
+
+
+
+
+
Unused Stock
+

+
+
+ +
+
+
+
+
+
+
+
+
Active Subscriptions
+

+
+
+ +
+
+
+
+
+ +
+
+
+
Voucher Batches (All Resellers)
+

System-wide batch operations, scratch card exporting, and inventory audit.

+
+ +
+ +
+ +
+
+ +
+
No batches created yet
+

Generate a batch of active codes to start managing vouchers.

+ Generate Codes +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Batch NameCreator / ResellerPackageBreakdownDate CreatedActions
+
+
+ +
+ +
+
+ + + + +
+ Stock + Active + Total +
+
+
+
+
+
+ + + +
+
+ +
+
+ + + + + + + diff --git a/src/Public/Views/admin/active_codes_mass.php b/src/Public/Views/admin/active_codes_mass.php new file mode 100644 index 00000000..7a8256dd --- /dev/null +++ b/src/Public/Views/admin/active_codes_mass.php @@ -0,0 +1,198 @@ + + +
+
+
+
+
+
Mass Edit Active Codes
+

Apply bulk changes to codes across a batch, a reseller, or specific codes.

+
+ + Back to Codes + +
+ +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
Adds this number of days to all currently active codes matching the filter.
+
+ +
+ + +
Updates both the voucher package and bouquets on the linked subscriber line.
+
+ +
+ + +
+
+
+
+
+
+ + + + + + + diff --git a/src/Public/Views/admin/code.php b/src/Public/Views/admin/code.php index fdaee931..9a85fc4d 100644 --- a/src/Public/Views/admin/code.php +++ b/src/Public/Views/admin/code.php @@ -14,7 +14,7 @@ use XcVm\Domain\User\GroupService; $rIsEdit = isset($rCode); $rCodeGroups = ($rIsEdit && !empty($rCode['groups'])) ? (json_decode((string) $rCode['groups'], true) ?: []) : []; $rWhitelist = ($rIsEdit && !empty($rCode['whitelist'])) ? (json_decode((string) $rCode['whitelist'], true) ?: []) : []; -$rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'Web Player']; +$rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'Web Player', 7 => 'Active Code Portal']; ?>
@@ -67,6 +67,27 @@ $rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'W >
+ +
+
+
+ +
+
+
Active Code Portal Direct Access
+

+ Subscribers can open this URL to input their activation codes, receive Xtream Codes credentials, and download playlists. +

+
+ + + +
+
+
+
@@ -134,6 +155,89 @@ renderUnifiedLayoutFooter('admin'); } + // Dynamic preview for Web Player (6) and Active Code Portal (7) + var typeSelect = document.getElementById('type'); + var codeInput = document.getElementById('code'); + var previewBox = document.getElementById('portal-preview-box'); + var previewTitle = document.getElementById('preview-box-title'); + var previewDesc = document.getElementById('preview-box-desc'); + var previewUrl = document.getElementById('portal-url-preview'); + var previewIcon = document.getElementById('preview-box-icon'); + var copyPreviewBtn = document.getElementById('btn-copy-preview-url'); + var tabGroupsBtn = document.querySelector('button[data-bs-target="#tab-groups"]'); + + function updatePreview() { + var typeVal = parseInt(typeSelect.value, 10); + var codeVal = codeInput.value.trim(); + var origin = window.location.origin; + var fullUrl = origin + '/' + (codeVal ? encodeURIComponent(codeVal) : '...') + '/'; + + if (typeVal === 7) { + previewBox.classList.remove('d-none'); + previewTitle.textContent = 'Active Code Portal (Subscriber Activation)'; + previewDesc.textContent = 'Subscribers open this URL to enter their activation codes, view Xtream Codes credentials, and download playlists.'; + previewIcon.className = 'icon-base ti tabler-key fs-5'; + previewUrl.textContent = fullUrl; + if (tabGroupsBtn) { + tabGroupsBtn.classList.add('disabled', 'opacity-50'); + tabGroupsBtn.title = 'Groups do not apply to subscriber portals'; + } + } else if (typeVal === 6) { + previewBox.classList.remove('d-none'); + previewTitle.textContent = 'Web Player Direct Access'; + previewDesc.textContent = 'Subscribers open this URL to stream channels and VOD directly in their web browser.'; + previewIcon.className = 'icon-base ti tabler-device-tv fs-5'; + previewUrl.textContent = fullUrl; + if (tabGroupsBtn) { + tabGroupsBtn.classList.add('disabled', 'opacity-50'); + tabGroupsBtn.title = 'Groups do not apply to web players'; + } + } else { + previewBox.classList.add('d-none'); + if (tabGroupsBtn) { + tabGroupsBtn.classList.remove('disabled', 'opacity-50'); + tabGroupsBtn.removeAttribute('title'); + } + } + } + + typeSelect.addEventListener('change', updatePreview); + codeInput.addEventListener('input', updatePreview); + updatePreview(); + + if (copyPreviewBtn) { + copyPreviewBtn.addEventListener('click', function(e) { + e.preventDefault(); + var txt = previewUrl.textContent; + if (!txt) return; + var p = (navigator.clipboard && window.isSecureContext) + ? navigator.clipboard.writeText(txt) + : new Promise(function(resolve, reject) { + try { + var textarea = document.createElement('textarea'); + textarea.value = String(txt); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.setAttribute('readonly', ''); + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + var success = document.execCommand('copy'); + document.body.removeChild(textarea); + success ? resolve() : reject(); + } catch (err) { + reject(err); + } + }); + p.then(function() { + xcToast('Copied portal URL to clipboard!', 'success'); + }).catch(function() { + prompt('Copy portal URL:', txt); + }); + }); + } + // Group select-all / none. document.getElementById('grp-all').addEventListener('click', function() { document.querySelectorAll('.group-checkbox').forEach(function(c) { diff --git a/src/Public/Views/admin/codes.php b/src/Public/Views/admin/codes.php index 853e539e..4f9d9402 100644 --- a/src/Public/Views/admin/codes.php +++ b/src/Public/Views/admin/codes.php @@ -20,12 +20,24 @@ if (!Authorization::check('adv', 'add_code')): endif; // Access-code type labels (matches the code form's type select). -$rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API', 4 => 'Reseller API', 6 => 'Web Player']; +$rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API', 4 => 'Reseller API', 6 => 'Web Player', 7 => 'Active Code Portal']; +$rTypeBadges = [ + 0 => 'bg-label-primary', + 1 => 'bg-label-warning', + 2 => 'bg-label-secondary', + 3 => 'bg-label-dark', + 4 => 'bg-label-dark', + 6 => 'bg-label-success', + 7 => 'bg-label-info', +]; ?>
-
+
@@ -44,12 +56,26 @@ $rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API', - - + + @@ -124,6 +150,40 @@ renderUnifiedLayoutFooter('admin'); }); }); }); + + function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + try { + var textarea = document.createElement('textarea'); + textarea.value = String(text); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.setAttribute('readonly', ''); + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + var success = document.execCommand('copy'); + document.body.removeChild(textarea); + success ? resolve() : reject(); + } catch (err) { + reject(err); + } + }); + } + + jQuery('#codes-table tbody').on('click', '.js-copy-link', function() { + var code = this.getAttribute('data-code'); + var fullUrl = window.location.origin + '/' + encodeURIComponent(code) + '/'; + copyToClipboard(fullUrl).then(function() { + xcToast('Access URL copied to clipboard!', 'success'); + }).catch(function() { + prompt('Copy Access URL:', fullUrl); + }); + }); })(); diff --git a/src/Public/Views/layouts/reseller/header.php b/src/Public/Views/layouts/reseller/header.php index 613e7956..24e0abe4 100644 --- a/src/Public/Views/layouts/reseller/header.php +++ b/src/Public/Views/layouts/reseller/header.php @@ -100,6 +100,17 @@ $xmMenu = [ ], ], ], + [ + 'label' => 'active_codes', + 'icon' => 'ti tabler-key', + 'url' => '#', + 'show' => !empty($xmPermissions['create_line']), + 'children' => [ + ['label' => 'generate_codes', 'url' => 'active_code', 'show' => true], + ['label' => 'manage_codes', 'url' => 'active_codes', 'show' => true], + ['label' => 'batch_manager', 'url' => 'active_codes_batch', 'show' => true], + ], + ], [ 'label' => 'content', 'icon' => 'ti tabler-player-play', diff --git a/src/Public/Views/portal/index.php b/src/Public/Views/portal/index.php new file mode 100644 index 00000000..12bc29d0 --- /dev/null +++ b/src/Public/Views/portal/index.php @@ -0,0 +1,846 @@ + + + + + + + + + + <?= htmlspecialchars($serverName, ENT_QUOTES); ?> | Subscriber Activation Portal + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+ + Subscriber Activation Portal + + + Service Online + +
+
+
+ + +
+
+ + +
+
+

+ Smart Code Activation +

+

Redeem voucher codes, stream IPTV, and access live playlist endpoints.

+
+
+ Secure Stream Provisioning +
+
+ + +
+
+
+
Enter Activation Voucher
+

Enter your active voucher PIN to unlock your stream access.

+
+ + Instant Provision + +
+ +
+
+
+ +
+ + + +
+
+ + Letters and digits only • Case insensitive + + + Device Binding (Optional) + +
+
+ + +
+ +
+ + +
+
Leave blank unless your code is locked to a specific MAC / MAG STB.
+
+ + +
+ +
+ + + + +
+
+ + +
+
+
+
+ Subscription Activated Successfully +
+

Your streaming credentials and playlist links are now active and ready.

+
+
+ + Active + + + Premium Package + +
+
+ +
+ +
+
+
+ +
+ -------- + +
+
+
+ +
+
+ +
+ Full IPTV Package + + 1 Connection + +
+
+
+
+ + +
+
+ + Subscription Remaining Time + + + Expires on: + +
+
+
+
00
+
Days
+
+
+
00
+
Hours
+
+
+
00
+
Minutes
+
+
+
00
+
Seconds
+
+
+
+ + +
+
+
+ Xtream Codes & Streaming Credentials +
+
+
+
+
+ Username + +
+
--
+
+
+ +
+
+
+ Password +
+ + +
+
+
--
+
+
+ +
+
+
+ Server Host / URL + +
+
--
+
+
+ +
+
+
+ Port + +
+
--
+
+
+
+ +
+ + + +
+
+ + +
+ + +
+
+
+
+
+ + +
+
+ + © . All rights reserved. Powered by Smart Activation Engine. + +
+
+ + + + + + diff --git a/src/Public/Views/reseller/active_code.php b/src/Public/Views/reseller/active_code.php new file mode 100644 index 00000000..904a17f2 --- /dev/null +++ b/src/Public/Views/reseller/active_code.php @@ -0,0 +1,724 @@ + 0) { + $cost = floatval($override[$pkgId]['official_credits']); + } else { + $cost = floatval($pkg['official_credits'] ?? 0); + } + } + $packagePrices[$pkgId] = [ + 'cost' => $cost, + 'is_trial' => $isTrial, + 'name' => (string)$pkg['package_name'], + 'duration' => (int)($isTrial ? $pkg['trial_duration'] : $pkg['official_duration']), + 'duration_in' => (string)($isTrial ? $pkg['trial_duration_in'] : $pkg['official_duration_in']), + 'max_connections' => (int)($pkg['max_connections'] ?: 1), + 'forced_country' => (string)($pkg['forced_country'] ?? ''), + 'bouquets' => json_decode((string)($pkg['bouquets'] ?? '[]'), true) ?: [], + ]; +} + +// Streaming DNS options +$dnsList = array_filter(array_map('trim', explode(',', (string)($rUserInfo['reseller_dns'] ?? '')))); +?> + + +
+ +
+
+
+
+
+

Pre-generate stock activation vouchers for clients. Credits are deducted, but countdown begins only upon first activation.

+
+ + Back to Codes + +
+ +
+
+ +
+
+ +
+ + + +
+
Used to group vouchers for batch export, printing, and management.
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ +
+
+
+ +
+
+ +
Select streaming content categories for this voucher
+
+
+
+ + / Selected + +
+
+ + +
+ +
+ + + +
+ + +
+ + + + +
+
+ + +
+ + + + 0): + ?> + + +
+ + +
+
+ +
+ +
+ +
+ +
No bouquets found matching your filter.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ Credit Accounting + +
+ +
+ Your Current Balance +

Credits

+
+ +
+
+ Cost Per Voucher: + 0.00 Credits +
+
+ Quantity: + 1 +
+
+
+ Total Cost: + 0.00 Credits +
+
+ +
+ Balance After Generation: + Credits +
+ +
+ Insufficient balance for this generation request. +
+
+
+ + +
+
+
+ How Active Codes Work +
+
    +
  • + +
    + Stock Mode (Delayed Timer) + Codes sit safely in your inventory without aging. The 30-day (or 1-year) countdown is triggered only when the buyer inputs the code into their TV app or web portal. +
    +
  • +
  • + +
    + Scratch-Card Batch Printing + Group vouchers by batch and download ready-to-print formatted text files for physical cards or retail store distribution. +
    +
  • +
  • + +
    + Safe Refund on Unused Vouchers + If you ever delete a batch of unused codes, your credits are automatically returned to your reseller balance. +
    +
  • +
+
+
+
+
+ + +
+
+
+ +
Codes Generated Successfully!
+
+
+ + View in Inventory +
+
+
+
+
+ Batch Name: + • + Total Vouchers: +
+ Open in Batch Manager +
+ +
+
+ + + + + + + + + + + +
+ + + + + + + + + + +
#Activation CodeCompanion UsernameCompanion PasswordInitial Status
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/Public/Views/reseller/active_codes.php b/src/Public/Views/reseller/active_codes.php new file mode 100644 index 00000000..d29ccddb --- /dev/null +++ b/src/Public/Views/reseller/active_codes.php @@ -0,0 +1,633 @@ + + +
+
+
+
+

Pre-generated stock vouchers. Subscriptions count down only upon client's first activation.

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + +
+ + CodeBatchPackageStatusExpirationSubscriberDevice LockCreatedActions
+
+
+ + +
+
+ 0 + Codes Selected +
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/Public/Views/reseller/active_codes_batch.php b/src/Public/Views/reseller/active_codes_batch.php new file mode 100644 index 00000000..0736e067 --- /dev/null +++ b/src/Public/Views/reseller/active_codes_batch.php @@ -0,0 +1,283 @@ + + + +
+
+
+
+
+
Total Batches
+

+
+
+ +
+
+
+
+
+
+
+
+
Total Generated
+

+
+
+ +
+
+
+
+
+
+
+
+
Stock / Unused
+

+
+
+ +
+
+
+
+
+
+
+
+
Active Subscriptions
+

+
+
+ +
+
+
+
+
+ + +
+
+
+
Batch Manager
+

Grouped voucher management, scratch card voucher exporting, and batch operations.

+
+ +
+ +
+ +
+
+ +
+
No batches created yet
+

Generate your first batch of active codes to begin managing vouchers.

+ Generate Codes +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Batch NamePackageVouchers BreakdownCreated DateBatch Actions
+ + + + + Trial + + +
+ Stock + Active + Total +
+
+
+
+
+
+ + + +
+
+ +
+
+ + + + + + + + + + diff --git a/src/Public/index.php b/src/Public/index.php index f5c55a7d..9a0c485f 100644 --- a/src/Public/index.php +++ b/src/Public/index.php @@ -5,6 +5,7 @@ use XcVm\Core\Module\ModuleLoader; use XcVm\Infrastructure\Bootstrap\ScopeBootstrapFactory; use XcVm\Infrastructure\Bootstrap\StreamingRequestBootstrap; use XcVm\Infrastructure\Bootstrap\WebApiBootstrap; +use XcVm\Public\Controllers\Api\ActiveCodeApiController; use XcVm\Public\Controllers\Api\AdminApiController; use XcVm\Public\Controllers\Api\Enigma2ApiController; use XcVm\Public\Controllers\Api\EpgApiController; @@ -13,6 +14,7 @@ use XcVm\Public\Controllers\Api\PlayerApiController; use XcVm\Public\Controllers\Api\PlaylistApiController; use XcVm\Public\Controllers\Api\ResellerRestApiController; use XcVm\Public\Controllers\Api\XPluginApiController; +use XcVm\Public\Controllers\Player\PortalController; /** * Front Controller — единая точка входа для admin/reseller/player. @@ -62,6 +64,7 @@ if (!empty($_SERVER['XC_SCOPE'])) { 'includes/api/admin' => 'admin', 'includes/api/reseller' => 'reseller', 'player' => 'player', + 'portal' => 'portal', ]; $scope = $scopeMap[$rawScope] ?? 'admin'; @@ -73,8 +76,8 @@ if (!empty($_SERVER['XC_SCOPE'])) { $parts = explode('/', $pageName, 2); $pageName = $parts[1] ?? ''; } -} elseif (preg_match('#^/(admin|reseller)(?:/(.*))?$#', $urlPath, $m)) { - // Режим B: прямой URL /admin/... или /reseller/... +} elseif (preg_match('#^/(admin|reseller|portal)(?:/(.*))?$#', $urlPath, $m)) { + // Режим B: прямой URL /admin/... или /reseller/... или /portal/... $scope = $m[1]; $pageName = isset($m[2]) ? trim($m[2], '/') : ''; } else { @@ -127,11 +130,9 @@ if ( exit; } -// 4b. Player: /CODE (без завершающего слэша) → /CODE/ — страницы плеера ссылаются -// на статику относительно ("css/main.css"), без слэша браузер резолвит её от -// корня и весь CSS/JS уходит в 404. +// 4b. Player / Portal: /CODE (без завершающего слэша) → /CODE/ if ( - $accessCode && $scope === 'player' + $accessCode && in_array($scope, ['player', 'portal'], true) && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET' && rtrim(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '', '/') === '/' . $accessCode && substr(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '', -1) !== '/' @@ -162,8 +163,9 @@ if (isset($rawScope) && $rawScope === 'api' && !empty($_SERVER['XC_API'])) { 'enigma2' => [Enigma2ApiController::class, 'web'], 'xplugin' => [XPluginApiController::class, 'web'], 'epg' => [EpgApiController::class, 'web'], - 'playlist' => [PlaylistApiController::class, 'web'], - 'internal' => [InternalApiController::class, 'web'], + 'playlist' => [PlaylistApiController::class, 'web'], + 'internal' => [InternalApiController::class, 'web'], + 'active_code' => [ActiveCodeApiController::class, 'web'], ]; if (!isset($rApiEndpoints[$rApiName])) { @@ -202,6 +204,14 @@ if ($scope === 'ministra') { } } +// 6c. Subscriber Activation Portal (public access, no admin/reseller session required) +if ($scope === 'portal') { + WebApiBootstrap::init('portal'); + $portalController = new PortalController(); + $portalController->index(); + exit; +} + // 7. Scope bootstrap — working directory + session/functions files $adminDir = ($scope === 'admin') ? MAIN_HOME . 'Public/Views/admin/' : MAIN_HOME . $scope . '/'; @chdir(is_dir($adminDir) ? $adminDir : MAIN_HOME); diff --git a/src/Public/routes/admin.php b/src/Public/routes/admin.php index 180ab74f..e213e69e 100644 --- a/src/Public/routes/admin.php +++ b/src/Public/routes/admin.php @@ -1,7 +1,12 @@ get('isps', [IspController::class, 'index']); $router->get('hmacs', [HmacController::class, 'index']); $router->get('groups', [GroupController::class, 'index']); $router->get('codes', [CodeController::class, 'index']); +$router->get('active_codes', [ActiveCodesController::class, 'index']); +$router->get('active_code', [ActiveCodeController::class, 'index']); +$router->get('active_codes_batch', [ActiveCodesBatchController::class, 'index']); +$router->get('active_codes_mass', [ActiveCodesMassController::class, 'index']); $router->get('packages', [PackageController::class, 'index']); $router->get('rtmp_ips', [RtmpIpController::class, 'index']); $router->get('profiles', [ProfileController::class, 'index']); @@ -374,6 +383,12 @@ $router->api('category', [PackageAjaxController::class, 'category']); $router->api('get_package', [PackageAjaxController::class, 'getPackage']); $router->api('get_package_trial', [PackageAjaxController::class, 'getPackageTrial']); +// ─── Active Codes ────────────────────────────────── +$router->api('active_code_details', [ActiveCodeAjaxController::class, 'details']); +$router->api('generate_active_codes', [ActiveCodeAjaxController::class, 'generate']); +$router->api('active_codes_batch_action', [ActiveCodeAjaxController::class, 'batchAction']); +$router->api('active_codes_export_txt', [ActiveCodeAjaxController::class, 'exportTxt']); + // ─── Stats & Graphs ──────────────────────────────── $router->api('graph_stats', [StatsAjaxController::class, 'graphStats']); $router->api('stats', [StatsAjaxController::class, 'stats']); diff --git a/src/Public/routes/reseller.php b/src/Public/routes/reseller.php index ca69d191..9491c237 100644 --- a/src/Public/routes/reseller.php +++ b/src/Public/routes/reseller.php @@ -1,5 +1,8 @@ get('line', [ResellerLineController::class, 'index']); $router->get('line_activity', [ResellerLineActivityController::class, 'index']); $router->get('live_connections', [ResellerLiveConnectionsController::class, 'index']); +// ─── Smart Activation Codes ───────────────────────── + +$router->get('active_codes', [ResellerActiveCodesController::class, 'index']); +$router->get('active_code', [ResellerActiveCodeController::class, 'index']); +$router->get('active_codes_batch', [ResellerActiveCodesBatchController::class, 'index']); + // ─── Devices MAG / Enigma ────────────────────────── $router->get('mags', [ResellerMagsController::class, 'index']); diff --git a/src/bin/install/database.sql b/src/bin/install/database.sql index 26d478c7..f6c81939 100644 --- a/src/bin/install/database.sql +++ b/src/bin/install/database.sql @@ -26,6 +26,39 @@ CREATE TABLE IF NOT EXISTS `access_codes` ( -- -------------------------------------------------------- +-- +-- Table structure for table `activation_codes` +-- + +CREATE TABLE IF NOT EXISTS `activation_codes` ( + `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `activation_code` varchar(50) COLLATE utf8_unicode_ci NOT NULL, + `batch_name` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL, + `subscriber_id` int(11) NOT NULL DEFAULT '0', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=Ready/Stock, 2=Active/Bound, 0=Disabled', + `created_by` int(11) NOT NULL DEFAULT '0', + `package_id` int(11) DEFAULT NULL, + `bouquets` mediumtext COLLATE utf8_unicode_ci DEFAULT NULL, + `is_adult` tinyint(1) NOT NULL DEFAULT '0', + `is_trial` tinyint(1) NOT NULL DEFAULT '0', + `purchase_cost` decimal(10,2) NOT NULL DEFAULT '0.00', + `dns_base` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `forced_country` varchar(3) COLLATE utf8_unicode_ci DEFAULT NULL, + `max_connections` int(11) NOT NULL DEFAULT '1', + `mac` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL, + `device_id` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL, + `activated_at` int(11) DEFAULT NULL, + `created_at` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_activation_code` (`activation_code`), + KEY `idx_created_by` (`created_by`), + KEY `idx_subscriber_id` (`subscriber_id`), + KEY `idx_batch_name` (`batch_name`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + +-- -------------------------------------------------------- + -- -- Table structure for table `blocked_asns` -- @@ -344,6 +377,7 @@ CREATE TABLE IF NOT EXISTS `lines` ( `last_activity` int(11) DEFAULT NULL, `last_activity_array` mediumtext COLLATE utf8_unicode_ci, `updated` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_activecode` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `member_id` (`member_id`), KEY `exp_date` (`exp_date`), @@ -357,6 +391,7 @@ CREATE TABLE IF NOT EXISTS `lines` ( KEY `username` (`username`), KEY `password` (`password`), KEY `is_e2` (`is_e2`), + KEY `idx_is_activecode` (`is_activecode`), KEY `order_default` (`id`,`is_mag`,`is_e2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; diff --git a/src/bin/nginx/conf/nginx.conf b/src/bin/nginx/conf/nginx.conf index 9b6d0372..956935f8 100644 --- a/src/bin/nginx/conf/nginx.conf +++ b/src/bin/nginx/conf/nginx.conf @@ -109,6 +109,12 @@ http { location = /panel_api.php { rewrite ^ /api/player_api last; } + + location = /active_code.php { + rewrite ^ /api/active_code last; + } + + rewrite ^/api/v1/active-code/auth$ /api/active_code last; location = /streaming/live.php { return 302 /stream/auth?type=live&$args; @@ -277,7 +283,7 @@ http { } # ─── Streaming API → Front Controller ─────────────────────── - location ~ ^/api/(player_api|enigma2|xplugin|epg|playlist)$ { + location ~ ^/api/(player_api|enigma2|xplugin|epg|playlist|active_code)$ { limit_req zone=one burst=8; include limit_queue.conf; fastcgi_index index.php; @@ -295,7 +301,7 @@ http { } # ─── Streaming API → Front Controller (legacy *.php) ────── - location ~ ^/(player_api|enigma2|xplugin|epg|playlist)\.php$ { + location ~ ^/(player_api|enigma2|xplugin|epg|playlist|active_code)\.php$ { limit_req zone=one burst=8; include limit_queue.conf; fastcgi_index index.php; @@ -312,6 +318,23 @@ http { fastcgi_param XC_API $1; } + # ─── Subscriber Activation Portal ─────────────────────────── + location ^~ /portal { + limit_req zone=one burst=15; + include limit_queue.conf; + fastcgi_index index.php; + fastcgi_pass php; + include fastcgi_params; + fastcgi_buffering on; + fastcgi_buffers 128 32k; + fastcgi_buffer_size 32k; + fastcgi_max_temp_file_size 0; + fastcgi_keep_conn on; + fastcgi_param SCRIPT_FILENAME /home/xc_vm/Public/index.php; + fastcgi_param SCRIPT_NAME /public/index.php; + fastcgi_param XC_SCOPE portal; + } + # ─── Internal API → Front Controller (server-to-server) ──── # Аутентификация: InternalApiController проверяет password + IP whitelist location = /api { diff --git a/src/migrations/019_add_activation_codes.sql b/src/migrations/019_add_activation_codes.sql new file mode 100644 index 00000000..1aaed71f --- /dev/null +++ b/src/migrations/019_add_activation_codes.sql @@ -0,0 +1,39 @@ +-- Smart Activation Codes (core feature): prepaid voucher codes that a +-- subscriber redeems to provision a line. Generated/managed from admin and +-- reseller, redeemed through the player activation portal. +-- status: 1=Ready/Stock, 2=Active/Bound, 0=Disabled +-- Kept in sync with bin/install/database.sql (fresh-install baseline). +CREATE TABLE IF NOT EXISTS `activation_codes` ( + `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `activation_code` varchar(50) COLLATE utf8_unicode_ci NOT NULL, + `batch_name` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL, + `subscriber_id` int(11) NOT NULL DEFAULT 0, + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=Ready/Stock, 2=Active/Bound, 0=Disabled', + `created_by` int(11) NOT NULL DEFAULT 0, + `package_id` int(11) DEFAULT NULL, + `bouquets` mediumtext COLLATE utf8_unicode_ci DEFAULT NULL, + `is_adult` tinyint(1) NOT NULL DEFAULT 0, + `is_trial` tinyint(1) NOT NULL DEFAULT 0, + `purchase_cost` decimal(10,2) NOT NULL DEFAULT 0.00, + `dns_base` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `forced_country` varchar(3) COLLATE utf8_unicode_ci DEFAULT NULL, + `max_connections` int(11) NOT NULL DEFAULT 1, + `mac` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL, + `device_id` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL, + `activated_at` int(11) DEFAULT NULL, + `created_at` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_activation_code` (`activation_code`), + KEY `idx_created_by` (`created_by`), + KEY `idx_subscriber_id` (`subscriber_id`), + KEY `idx_batch_name` (`batch_name`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + +-- Flag lines auto-provisioned by an activation code, so the normal Lines +-- list/filters (admin + reseller) exclude them. +ALTER TABLE `lines` + ADD COLUMN IF NOT EXISTS `is_activecode` tinyint(1) NOT NULL DEFAULT 0; + +ALTER TABLE `lines` + ADD KEY IF NOT EXISTS `idx_is_activecode` (`is_activecode`);