Pre-generated stock voucher codes that pair with an auto-created line and
count down only on the client's first activation. Full admin + reseller
management — list, generate wizard, batch manager, mass edit — plus an
activation portal and a public activation API.
- schema: activation_codes table (migration 019 + database.sql)
- domain: ActiveCodeService (generate / activate / mass actions / export /
batch summary; view queries live here, not in the templates)
- admin + reseller controllers and views; PortalController + activation API
- nginx routes for /active_code.php and the active_code API endpoint
- wired into auth, users, reseller dispatcher, table/player APIs and navbar
Passes phpstan, phpcs, make gates, and the unit suite (524 tests).
Drop build/phpstan-baseline.neon (122 frozen pre-existing errors) and fix
the underlying issues in source instead of suppressing them. A fresh
level-5 run without the baseline surfaced 158 errors across 56 files; all
are resolved. `make phpstan`, `make cs`, and `make gates` are green.
Fix categories:
- variable.undefined — initialize vars to a correct default before the
branch/loop that conditionally set them, so every path defines them.
- return.type — align declared return types with reality (widen to
array|false / ?array / \Movie|null etc. where callers handle the
sentinel, or return the declared type consistently).
- argument.type — cast at call sites (curl_setopt/stream_set_blocking bool
flags, str_replace/mktime/uniqid operands, \CurlHandle phpdoc).
- redundant/dead conditions — simplify always-true guards and drop
unreachable else branches, preserving behavior.
Real latent bugs caught along the way:
- Core/Http/CurlClient — retry loop never incremented on failure and never
broke on success (could spin); now retries as documented.
- Core/Util/StreamUtils — explode('=', $x, 1) meant path/domain query
params were never parsed (limit 1 -> 2).
- Cli/CronJobs/RootSignalsCronJob — set_governor emitted an undefined PHP
$i into the cpufreq-set bash command (now a literal shell $i).
Root-cause analyzer fixes (not suppressions):
- Database::ping()/num_rows() marked @phpstan-impure so repeated calls are
not treated as constant (clears the ResellerApiDispatcher false cluster).
- StreamService::getArchive() $rReturn given an explicit @var for the
loop-accumulated shape PHPStan cannot infer from array().
Two residual entries are documented false positives (analyzer limitations,
not bugs) as path-scoped ignoreErrors in phpstan.dist.neon:
- CacheHandlerCommand:66 — settings force-reloaded from DB mid-run.
- EpgCronJob:314 — reconnect-verify ping() after db_connect().
Also drops an unused `use ...Epg\EPG;` import in admin/api.php.
Fixes warnings/exceptions from production panel logs (v2.3.9), mostly
unguarded access to keys of external provider payloads and to files that
can vanish under races:
- ProvidersCronJob: suppress file_get_contents noise on unreachable
providers (readURL), and normalise each stream row with defaults
(`+= [...]`) so missing stream_id/category_id/name/stream_icon/
epg_channel_id/container_extension no longer warn; skip rows with no
stream_id. Guard category_id/category_name in the category feeds.
- StreamRedirector: guard the cached stream_ file read (missing cache
file no longer warns or produces a half-built $rStream), and default
info.direct_source/direct_proxy.
- stream/probe.php: guard $rChannelInfo (redirectStream may return false)
and the decoded stream_info before reading codecs/container/bitrate.
- admin/live.php: @filesize on a segment .ts that may be rotated away
mid-read.
- PlayerApiController: default parse_url()['path'/'host'] and
$_SERVER['HTTP_USER_AGENT'].
- UserRepository::applyIspInfo: initialise isp_asn default so it is never
read undefined when the GeoIP lookup returns no ISP.
- admin/providers.php: default max_connections/active_connections for
providers whose last fetch failed.
getE2Info() never referenced its third parameter; the only caller
(XPluginApiController) passes just $rDevice and relies on defaults, so removing
the dead middle argument shifts nothing.
Verified: php -l, phpunit (435 tests), make gates.
getStreamingUserInfo used the older GeoIP stack (GeoIPService + the boot-loaded
$rBlockedISP/$rBlockedServers globals + BlocklistService::checkISP/checkServer)
while getUserInfo used the newer one (GeoIP + BlocklistService::getBlocked*).
The two are functionally equivalent - same MaxMind DBs, same on-disk caches,
identical block-check logic, same blocked_isp/blocked_servers data - so
standardise getStreamingUserInfo on the newer stack, gaining GeoIP's
defined()-guard + try/catch and BlocklistService's DB fallback / 20s refresh.
With the ISP and forced_country blocks now identical across both methods, hoist
them into applyIspInfo() / applyForcedCountry(). getStreamingUserInfo becomes a
~15-line orchestration of shared helpers and getUserInfo collapses to the thin
wrapper its docblock always claimed to be. Drops the last global-state
dependency and the now-unused GeoIPService import.
Behaviour preserved. Validated live: player_api/testxc auth=1, Active,
allowed_output_formats intact (the pre-existing GENERATE_PLAYLIST_FAILED on this
test box reproduces identically on the prior commit - unrelated).
Verified: php -l, phpunit (435 tests, 973 assertions), make gates.
Signals — the file-backed queue of deferred DB writes drained by the
cache-handler daemon — were produced two different ways: five call sites went
through RedisManager::setSignal() while getStreamingUserInfo inlined the same
file_put_contents(). Worse, the writer lived on RedisManager yet wrote a *file*,
not Redis — a misleading home that made the two paths look like different
transports (they were byte-identical).
Introduce XcVm\Infrastructure\Signal\SignalQueue as the single owner of the
on-disk contract (cache_<md5(key)> holding [key, data]):
- push() - the one producer API; all 7 call sites use it now
(UserRepository x4, auth.php x2, BruteforceGuard x1)
- pending() - the drain API; CacheHandlerCommand reads through it instead of its
own glob + json_decode
- pathFor() / PREFIX - path helpers
RedisManager::setSignal() stays as a thin @deprecated alias delegating to
SignalQueue::push() for any out-of-tree callers.
Behaviour unchanged (same files, same format, same consumer switch). Adds
SignalQueueTest (format / idempotency / drain / malformed). Validated live:
player_api/testxc still authenticates (auth=1) with SignalQueue autoloaded.
Verified: php -l, phpunit (435 tests, 973 assertions), make gates.
The two methods were ~90% duplicate, differing only in how they obtain
settings/cache/bouquets and in the divergent GeoIP + signal backends
(GeoIPService/file-signals vs GeoIP/Redis) that stay inline. Extract the
identical blocks into private helpers, called by both:
loadUserRow - credential resolution (token/cache file or DB),
resolving $rUserID by reference so the cached
re-verification keeps its exact behaviour
verifyCachedCredentials - cached access-token / username+password re-check
decodeUserFields - JSON line fields -> arrays
resolveOutputFormats - allowed output-format keys
aggregateBouquetIds - bouquet -> channel/series/vod/live/radio id lists
resolveCategoryIds - bouquet -> category ids
Also de-obfuscate the remaining `if (cond) {} else { body }` blocks in both
methods. Net -74 lines despite adding the shared helpers (~240 lines of
duplication removed). Behaviour preserved; the file-vs-Redis signal divergence
is intentionally left as-is (separate decision).
Tests: UserRepositoryTest covers the pure helpers (aggregate/category/decode/
verify), 12 tests total. Validated live: player_api/testxc returns auth=1,
Active, allowed_output_formats=[m3u8,ts,rtmp]; on-demand /live start still works.
Verified: php -l, phpunit (430 tests, 962 assertions), make gates.
- getE2Info: collapse the obfuscated `if (cond) {} else { body }` chain into
straight positive-form ifs; drop the redundant re-init of pair_line_info.
- getUserInfo: same de-obfuscation of the ISP block, and fix the same
"Undefined array key isp_asn" bug already fixed in getStreamingUserInfo — the
ISP-persist step ran even on a GeoIP miss (con_isp_name null), reading the
undefined isp_asn key and writing a null isp_desc/as_number to the line.
- Extract the persist predicate both methods shared into a pure, tested
UserRepository::ispChanged() helper (+ tests/Unit/UserRepositoryTest.php).
Verified: php -l, phpunit (423 tests, 944 assertions), make gates.
getStreamingUserInfo() sets isp_asn / con_isp_name only when
GeoIPService::getISP() actually returns an ISP. The follow-up block that
persists a changed ISP still ran when none was detected (con_isp_name stays
null): it read the undefined isp_asn key — the "Undefined array key isp_asn"
warning from UserRepository.php:348 — and wrote a null isp_desc/as_number to the
line. Gate it on a non-empty con_isp_name, matching the isp_violate check above.
Replace the fragile per-class setDb()/db() pattern (which threw when a
bootstrap path forgot to wire $db) with a shared trait that lazily resolves
from DatabaseFactory. Fixes the streaming UserRepository fatal and the same
latent issue in module crons. 42 classes converted.
LoadBalancer nginx had no ^/admin/ location, so when a server higher in
the tree pulled a stream whose source is an LB, the LB returned 404 for
/admin/{live,timeshift,thumb,vod} before the request ever reached PHP.
The MAIN→LB direction worked (MAIN has the route); LB→MAIN was silently
broken — stream shown down, nothing in the panel logs.
Add a reduced admin location exposing ONLY the loopback handlers
(live|timeshift|thumb|vod) — not index|api|proxy_api — so the admin
panel itself is never served from a LoadBalancer. Mirrors the MAIN
admin gateway block.
Also fix a PSR-4 rename miss: the directory rename public/ → Public/
(1a296d09) updated src/bin/nginx/conf/nginx.conf but left
lb_configs/nginx.conf pointing at /home/xc_vm/public/ everywhere
(root, stream, progress, api). On a case-sensitive fresh LB install the
on-disk path is Public/, so every SCRIPT_FILENAME 404'd — all LB
streaming broke, not just loopback. Repoint the 8 filesystem paths to
Public/; SCRIPT_NAME stays /public/ (logical CGI path, matches MAIN).
Note: bin/nginx is update-excluded, so existing LBs keep their old
config and must be patched manually or reinstalled; only fresh installs
pick up the corrected lb_configs/nginx.conf.
Apply 'make cs-fix' — 493 files. Mechanical, import-block only:
- sort use statements alphabetically (class/function/const grouped);
- drop imports left unused by the PSR-4 migration (e.g. classes referenced by
leading-backslash FQCN whose redundant 'use' the automated insertion had added);
- one blank line after namespace and after the import block; collapse stray
blank lines around use.
No logic changes. Verified: php -l clean; PHPStan no errors; PHPUnit 295/295; and a
temporary PHPStan pass over src/Public/Controllers confirms no still-referenced
import was removed (0 unresolved classes). 'use' after inline HTML in view
templates is valid and aliases correctly (verified) — those imports are sorted too.
Move Core/Database into XcVm\Core\Database (DatabaseHandler, Database,
MigrationRunner, QueryHelper). First of the Core hub sub-layers.
- Namespace the 4 classes; DatabaseHandler extends Database (same namespace);
built-ins/ioncube qualified (\PDO, \PDOException, \Exception, \Throwable,
\XC_VM); Database keeps its 'use XcVm\Core\Logging\FileLogger;'.
- Add 'use XcVm\Core\Database\...;' to referencing files (DatabaseHandler 51,
Database 64, QueryHelper 29, MigrationRunner 3) + 2 test files (PHPStan does not
analyse tests/, so PHPUnit is the gate there).
- Rewrite pre-existing leading-backslash global refs (\DatabaseHandler etc., e.g.
in @param docblocks of ResellerApiDispatcher/ResellerTableRenderer) to the full
FQCN \XcVm\Core\Database\... — a 'use' import does not cover a leading-\
reference. Done with a lookbehind so FQCN continuations and use-lines are intact.
- phpstan-baseline.neon regenerated (292→292; pre-existing Database/migration_logic
findings re-anchored after class names in messages gained the namespace).
Verified: php -l clean; PHPStan no errors; PHPUnit 295/295.