Port of the RTL slice of Rosmi720/XC_VM@f34b4808. When the active language is
right-to-left (ar/fa/ur/he), the admin and reseller shells and the login page
render mirrored:
- Translator::isRtl() reports whether a language (or the current one) is RTL.
- admin/reseller headers and login set <html dir="rtl"> and load the Cairo web
font + assets/xcvm/rtl.css only for RTL languages (a no-op for LTR).
- rtl.css mirrors navigation, cards, tables and forms.
- Arabic language file (ar.ini) so an RTL language is actually selectable.
Adapted to this tree: only the language-driven RTL was taken (not the fork's
navbar language switcher or the per-user RTL toggle); isRtl is unit-tested.
Port of Rosmi720/XC_VM@0d39e9bd. A mode-dropping deploy could leave core
scripts non-executable and a php-fpm pool config missing, and an undefined HOST
constant broke the unique-header cookie domain:
- StartupCommand restores the executable bit on service/update/redis-server/
daemons.sh and regenerates any missing php-fpm pool config (1..4.conf) from
the template on boot.
- StreamAuthMiddleware derives the cookie domain through a HOST-guarded helper,
so an undefined/empty host or a bare IP yields no domain instead of throwing
or emitting a broken "." domain.
Adapted to this tree: the added boot blocks were split into covered helpers
(ensureExecutableScripts / ensurePhpFpmPoolConfigs / uniqueHeaderDomain) with
unit tests to stay within the complexity ceiling, the sudo chown now runs only
as root, and comments were translated to English.
Port of Rosmi720/XC_VM@b44abddf. The rogue-connection sweep assumed
ConnectionTracker::getConnections() always returned a MySQL map grouped by
user id and iterated it with a flat double-loop. In Redis mode it returns a
[keys, data] pair, so the inner loop hit string keys and threw a TypeError,
aborting the cron.
Parse the result defensively via a small recursive helper (collectUuidRows)
that flattens any of the shapes — the Redis [keys, data] pair, the MySQL
user-id map and a single connection row — to the rows carrying a uuid, then
filter to the current stream. Both helpers are pure and unit-tested.
Adapted to this tree: the ar.ini/en.ini label tweaks target keys not present
here, so only the cron fix was ported; the added guards were split into
covered helpers to keep loadCron within the complexity ceiling.
Port of Rosmi720/XC_VM@4e91fb68. Encrypted playlist entries carried the output
extension as a URL fragment (play/<token>#.<ext>), which the client strips and
nginx never sees, so every encrypted stream fell back to .ts — VOD/HLS were
effectively broken. Encode the extension in the path instead:
- PlaylistGenerator emits play/<token>.<ext> for live and VOD (Cloudflare TS
keeps the bare token). The live/VOD suffix is a small pure helper
(encryptedPlaySuffix, unit-tested) so generate() stays within its ceiling.
- nginx rewrites play/<token>.<ext> to the auth handler, keeping the legacy
play/<token>/<ext> and bare-token rules for already-saved playlists.
- stream/auth.php splits a <token>.<ext> token and fills the extension when
nginx did not.
- settings label uses the new encrypt_playlists key (en.ini).
Adapted to this tree: ar.ini is absent here, so only en.ini was updated.
Port of Rosmi720/XC_VM@2140b00f. A missing/corrupt MaxMind database or an
undefined HOST constant could fatal the stream request (white screen on live):
- GeoIPService::getIPInfo()/getISP() no longer construct the MaxMind reader
unguarded. A missing constant/binary or any reader error now yields false
instead of a fatal, via a shared readMmdb() helper (unit-tested).
- StreamRedirector::getStreamingURL() reads HOST through a defined() guard so
an undefined constant does not throw while resolving the broadcast domain.
Adapted to this tree: the added guards/try-catch were folded into readMmdb so
getIPInfo/getISP stay within the complexity ceiling.
Port of Rosmi720/XC_VM@8f41d206. Harden the streaming user-info path against
null/malformed data that caused a fatal TypeError on player login:
- ImageUtils::validateURL() accepts null/empty (nullable icon columns) → ''.
- UserRepository::decodeUserFields() tolerates already-decoded arrays, null
columns and malformed JSON (via a shared decodeJsonField helper).
- aggregateBouquetIds() skips non-array bouquet groups (mergeBouquetGroup
helper); getStreamingUserInfo() resolves the bouquet map from the shared
cache / DB when the caller passes none (resolveBouquets helper).
- PlayerApiController::getOutputFormats() guards a non-iterable input and
falls back to the full format set.
Adapted to this tree: kept the existing null-safe category_map / active_cons
handling, and split the added guards into small covered helpers to stay within
the complexity ceiling (unit test for resolveBouquets).
Add category-template selection to the line, mag and enigma edit forms and to
the reseller active-code (voucher) form, plus a reseller navbar entry. Extract
the shared custom_data resolver into CategoryTemplateService::applyCustomData()
and route the Line/Mag/Enigma saves through it, removing the triplicated inline
block; covered by a new unit test.
Review fixes folded in:
- getRecentBatchNames filtered a non-existent `user_id` column and used
DISTINCT + ORDER BY id (rejected by MySQL ONLY_FULL_GROUP_BY). Restore the
correct created_by + GROUP BY MAX(created_at) query (kept the empty-batch
guard).
- routes/admin.php: import ActiveCodeDetailsController (fatal short-name use
without it) for the active_code_details route.
- Revert the accidental re-inline of the ancestorOwnerIds() helper in
getTemplatesForUser and the getResellersWithCodes all-users fallback.
Also: settings save only zeroes checkbox flags on the full settings form
(submit_settings), and reseller bootstrap tolerates a missing language.
Ported from PR #198 (Rosmi720/XC_VM): shared category templates were only
visible to the sharer's direct sub-resellers. Widen visibility to the whole
hierarchy so a template shared by any ancestor reseller reaches every
descendant.
- getTemplatesForUser: walk the ownership chain upward (new cycle-guarded
ancestorOwnerIds helper) and match is_shared templates owned by any
ancestor, not just the direct parent.
- canAccessTemplate: accept a shared template when the caller sits anywhere
in the owner's sub-tree; this subsumes the old direct-parent check.
- Reseller list: add a "Shared with Me" scope tab + count, driven by the
existing scope_type='shared' classification.
- Add CategoryTemplateVisibilityTest covering ancestor visibility and the
access guard (also keeps both methods under the CRAP ratchet).
Deliberately excluded from PR #198: the admin category_template(s) view
rewrites (a competing older implementation that reintroduces the xcToast
argument-order bug and drops the ?create=1 dialog deep-link already fixed
locally), and its language/RTL/active-code/dashboard changes (out of scope).
`make main`/`make lb` now stamp a unique per-build id — version + short git
SHA + UTC timestamp + 8 random bytes — into RELEASE_ID at the staged deploy
root (new stamp_release_id target, run after the copy step; the file is
generated per build, never git-tracked). ConstantsInitializer exposes it at
file scope as XC_VM_BUILD_ID by reading RELEASE_ID relative to __DIR__; a
source/dev checkout has no file and reports 'dev'.
This gives every build a traceable fingerprint so a leaked or rebranded copy
can be tied back to its origin (and, once activation ships, reported in the
phone-home alongside install_id). Kept at file scope to avoid adding a branch
to ConstantsInitializer::init() (CRAP ratchet). README discloses it.
Tests: XC_VM_BUILD_ID defined and defaults to 'dev' in a source checkout.
Full suite (817) + gates + CRAP + phpcs green; Makefile targets validated.
Adds LicenseGate::fanoutUsable() — the single "should this request use the
fanout daemon?" check (control socket present AND licence permits it). The two
fanout choke points now consult it instead of bare file_exists(FANOUT_CTL_SOCK):
StreamProcess::supervisionEnabled() and the three delivery tests in
Public/stream/live.php. When it returns false, live streams fall back to the
legacy delivery path automatically (soft enforcement) — nothing is hard-blocked.
The licence verdict itself (HWID-bound key tied to install_id, hidden
method-selector bytes, weekly re-validation, 7-day offline grace) lives in the
compiled xcvm_core extension; PHP only reads it via \XC_VM::license_valid()
when present. Fail-open where the extension or method is absent (dev/CI and
installs without xcvm_core), so this is behaviour-preserving until the C side
ships the verdict — and never bricks a working panel.
Helper kept in the new LicenseGate (not FanoutClient) to avoid touching that
debt-heavy file. supervisionEnabled() simplifies (cc 4->2). README disclosure
for activation will land when the verdict goes live.
Tests: fail-open without extension, unusable without control socket. Full
suite (816) + gates + CRAP + phpcs green.
Adds a disclosed, reversible attribution-integrity check (AGPL-3.0 §7(b)).
The "Vateron Media · AGPL-3.0" credit now has a single source
(AdminHelpers::getAttribution(), reused by getFooter). AttributionGuard
verifies its markers; AttributionVerificationStage runs the check on the
Admin (UI) boot profile only — admin/reseller/player panels — and calls
generateError('ATTRIBUTION_REMOVED') when the notice is gone.
Scope is deliberately UI-only: streaming is NOT gated, so removing a UI
footer cannot cut off a reseller's end-viewers (that is the licence
layer's concern). The lock is non-destructive and auto-reverses — the CLI
stays usable and restoring the notice unlocks the panel on the next
request. Enforcement is documented in README (License Enforcement).
This is a readable-PHP deterrent (a determined actor can remove the check
with the notice); tamper-resistant enforcement belongs in the compiled
xcvm_core extension (planned).
Tests: attribution markers, hasMarkers tamper detection, CLI self-skip,
UI-only wiring, error-code registration. Full suite + gates + CRAP green.
The cs-fix pass (758a9cab) typed ImageUtils::validateURL()'s URL and
StreamSorter::formatTitle()'s title as non-nullable strings, but both
come from nullable columns (streams.stream_icon / stream_display_name,
streams_series.title / cover, …). get_live_streams passed a channel's
NULL stream_icon straight in, so one channel without an icon turned the
line's entire live list into a TypeError page — and the same for any of
the ~100 validateURL() callers (series covers, backdrops, episode
images, admin and reseller views) and a title-less stream or series.
Both now take null as '' (unit-tested). player_api also no longer hands
filterLoaded() a null when the categories cache file is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
player_api loads the bouquet list only for the three stream-list
actions and passes null otherwise, but the cs-fix pass (758a9cab) typed
getStreamingUserInfo()'s $rBouquets as a non-nullable array. So the
sign-in call every app makes first, the category calls, the EPG and info
calls — even a wrong password — died with a TypeError page instead of
JSON: no player could sign in. The Ministra portal passes the cache's
null the same way when the bouquet cache file is missing.
Accept null as "no bouquets". Also skip a bouquet newer than the
category map (built by the heavy cache pass) instead of warning about an
undefined key — that warning alone broke the JSON where errors display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
Views/admin/functions.php duplicated, almost line for line, the body of
AdminScopeBootstrap::bootFunctions() (the front-controller admin boot), and
was also include'd by the JSON DataTables endpoint — where its cookies /
redirects / setup-COUNT query are inappropriate and the relative include was
fragile (only resolved via the FC's @chdir).
- AdminScopeBootstrap: expose the boot body through a public
hydrateAdminContext() entry point; guard the setcookie()/header() calls with
headers_sent() so the view scripts can call it after output has begun;
validate the session via the shared predicate.
- SessionManager::adminSessionValid() is now the single definition of the admin
session-integrity check (user/is_admin + login-IP + verify hash), split into
adminIdentityValid()/adminIpAllowed() and covered by SessionManagerTest.
- The six $noBootstrapPages view scripts (login, logout, setup, database,
player, post) call AdminScopeBootstrap::hydrateAdminContext() in place of
include "functions.php", re-importing the view-facing globals in their own
scope (the load-bearing part of the old include).
- Admin TableController: the api_key / api_user_id / session branches share a
hydrateApiUser() helper; the session branch validates via adminSessionValid()
and returns JSON on failure (no cookies / redirect / setup query). index()'s
global is widened so Authorization::check sees $rUserInfo/$rSettings.
793 unit tests pass; make gates and the CRAP gate stay green.
FanoutConfig::desired maps a new fanout_debug setting to the daemon's debug_cats
config key: "" (off, the default), "all", or a comma-separated category list
kept to the daemon's known categories (unknown names dropped, so a typo turns
debug off rather than writing a file the daemon second-guesses). The daemon
applies it on its next config poll, so an operator can turn debug on for a
misbehaving node and off again without a restart that would drop every viewer.
The PHPUnit suite could not be run here — no PHP on this box has the
dom/mbstring/xml/xmlwriter extensions phpunit.phar needs — so the three tests
added to FanoutConfigTest are unrun; the debugCats() mapping was instead
verified directly by reflection (off/all/list/unknown all map as asserted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two control calls the daemon has always answered and the panel never made.
FanoutClient::memory() reads GET /memory, and status() now carries it — so it
lands in servers.watchdog_data through the watchdog that already writes that
array, and the admin server views read it back by key with no view change. The
daemon's RSS is dominated by the per-stream join rings, by design: one watched
channel holds its whole prebuffer. Until now the panel could see that a node
was running hot and nothing more, and "the rings are as big as the tuning says"
and "something else is growing" are different problems with different answers.
FanoutClient::unregisterAll() reads DELETE /streams, the bulk teardown for a
maintenance drain or a decommission, instead of scripting a DELETE per id. The
daemon logs each teardown, so it stays an auditable operator action.
Both answer null when the daemon is unreachable, which for the watchdog is an
ordinary state rather than an error, and status() skips the memory call when
the socket did not answer.
Verified by loading the class directly: with no control socket, status() keys
are running,socket,connections,memory with memory null, and both new calls
answer null. The PHPUnit suite could NOT be run here — no PHP on this box has
the dom/mbstring/xml/xmlwriter extensions phpunit.phar requires — so the two
tests added to FanoutClientTest are unrun; they assert exactly what the harness
above printed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semgrep (php.lang.security.exec-use) blocked stopMovie: it killed the
encode with a `kill -9 $(ps | grep -F <path>)` pipeline and deleted the
files with `rm <path>.*`, both built from strings. The inputs were an
int id and a constant path, but nothing in the code proved that to a
scanner, and one careless edit would have made it injectable.
Find the encodes through /proc with ProcessManager::findProcessPIDs and
kill them with ProcessManager::kill; delete the files with glob() and
unlink(). Behaviour is unchanged: the trailing dot still keeps movie 5
from matching movie 50, and a no-transcode movie's symlink is removed
without touching its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135TSeD5tJGsD9peTZskAVM
ActiveCodeService::generateCodes and massAction took several values from the
reseller's request that the reseller pages never send:
- package_id: any package, not only those the reseller's group sells.
- is_trial=1: priced the codes at the package's trial_credits (usually 0)
while issuing them as that package's official codes.
- max_connections: any connection count.
- change_package: any package, with no group check.
- enable: also set admin_enabled = 1, lifting an administrator's ban on the
code's line.
For non-admin callers the package must now be a line package offered to the
reseller's group (the list the reseller pages show), the trial price applies
only to trial packages, the connection count comes from the package, and
enable leaves admin bans alone. Administrators keep every option.
Free extension of codes and bouquet choice are offered by the reseller page
itself and are left as designed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
- Admin global search: SearchAjaxController asks CategoryService::getAllByType
(null) for every category type, as the method's docblock and body allow,
but the cs-fix pass typed the parameter `string`. Any search with results
threw and returned nothing.
- Movie and episode encoding: startMovie hands movie_subtitles to
buildSubtitleImport(string), and titles saved without subtitles store NULL.
The TypeError ended the queue daemon, which stopped encoding the whole
movie queue.
- Line authentication: every category save or reorder deleted the
category_map cache, which only the heavy cache pass rebuilds, up to five
minutes later. Meanwhile getUserInfo passed igbinary_unserialize(false) to
resolveCategoryIds(array) and player_api, the Ministra portal and probe
failed for every line. Category saves no longer delete the map (which
categories a bouquet reaches does not depend on a category's name, type or
order), and a missing map now means no categories instead of an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
"Prepare release 2.5.2" bumped XC_VM_VERSION and left two assertions on
'2.5.1', so the unit suite failed on main. The test now checks the version's
shape and that init() defines the value appConfig() reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
Administrator groups can be limited to a list of advanced permissions
(add_user, settings, database, …). None of it was applied:
- PageAuthorization::checkPermissions() and checkResellerPermissions(),
called without a page (every controller's requirePermission()), took the
page from SCRIPT_FILENAME. Under the front controller that is always
Public/index.php, so the page checked was "index", which no rule names:
every page opened for every administrator and reseller.
- post.php saves all 63 admin forms and checked the page "post", also
unnamed. A restricted administrator could save anything — settings,
servers, or their own user with member_group_id=1, becoming a full admin.
The page now comes from AdminHelpers::getPageName() (the route's
PAGE_NAME). post.php holds each action to the rule of the page it saves,
through checkPostAction(): add vs edit is decided by the form's `edit`
field instead of ?id=, mass_delete_* map to mass_delete, TMDb category
import to categories. The Enigma2 device page had no rule at all and now
has the MAG one (add_e2 / edit_e2). The administrator's own profile and
module settings stay open, as before.
Full administrators (group 1, or a group with no advanced list) are
unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
InputValidator::confirmIDs() kept an id when intval($id) > 0 but returned
the original value, and its callers implode the result into SQL `IN (...)`
lists (mass edit and delete of lines, users, streams, series; bouquet
contents; global search). '1) OR (1=1' has an intval of 1, so it passed the
filter and reached the query unchanged. It now returns the integers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
ResellerAPI::processData() is the whitelist every reseller line, MAG and
Enigma2 save goes through. Two fields in it could be turned on other
people's subscriptions:
- pair_id: a new line stored whatever id was sent. Pairing copies the paired
line's expiry and bouquets onto this one on every renewal
(MagService::syncLineDevices), so a reseller could buy the cheapest line
paired to another reseller's paying customer and ride that subscription
for free. MAG and Enigma2 saves already required the paired line to be
the reseller's own; now every type does.
- trial: accepted on an edit, where it reset the expiry for trial_credits
(usually 0). The trial quota counts lines by created_at, which an edit
does not change, so any existing line could be extended forever. The
forms only send it when creating; the API now does the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
The admin and reseller login pages redirected to basename($referrer) after a
successful sign-in. basename() only strips forward slashes, and browsers read
`\\evil.com` in a Location header as `//evil.com`, so a link to
/CODE/login?referrer=\\\\\\\\evil.com (backslashes halved twice by the input
cleaner) sent the freshly signed-in admin or reseller to another site.
AdminHelpers::loginRedirectTarget() accepts only what the auth redirects
produce — a page name with an optional plain query string — and falls back
to the dashboard for anything else, logout included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
Three fixes from the automated PR review:
- ErrorResponder::respondError() used `$httpCode === null` where the legacy
generateError() used `!$rCode`; a caller passing 0 now falls through to the 404
page again instead of emitting http_response_code(0). (+ test)
- StageProfiles::for(BootContext::WebApi) now throws instead of silently building
a wrong stage list from the common prefix/suffix — WebApi boots via
WebApiBootstrap, never the kernel. (+ test)
- LegacyCoreStage reads enable_cache via SettingsManager::getBool() (the typed
getter the Web API path used), rather than the raw get(). Behaviour is
equivalent (`!` already coerces) but the intent is clearer.
Add cheap unit tests for stages that need no DB or config extension:
SessionStage / FloodProtectionStage / HostVerificationStage self-skip under the
CLI SAPI, ProcessTitleStage and AdminShutdownStage run without error, and
StatusConstantsStage defines the STATUS_* codes. Unit-covered stages: 8 of 16
(the rest need a live MySQL + xcvm_core and are covered by the dev-container and
real-install smokes).
StreamService::process() runs the stream icon through
ImageUtils::downloadImage() when "download images" is on, and an empty icon
arrives there as null. The `string` type the cs-fix pass (758a9cab) put on
the parameter made that a TypeError, so saving a new stream with no icon
answered an empty page and created nothing. Series, movies, episodes, radios
and created channels hand it optional covers and backdrops the same way.
downloadImage() now takes null and hands back whatever it cannot download
unchanged, null included. Found by the new E2E stream test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
WebApiBootstrap now reuses DatabaseStage + LegacyCoreStage for the "connect, wire
the domain services, run initCore, reconnect if the settings cache is incomplete"
step, so that logic has a single source of truth shared with the main kernel.
The web-API-specific parts stay inline because they are order-sensitive and not
container-based: the prelude split around the ini_set defaults, RequestGuard
(flood/host/PHP_ERRORS/Logger from the file cache), and the $gitRelease global.
The redundant explicit require_once of LegacyInitializer/DatabaseHandler/
GitHubReleases is dropped — those autoload. The endpoint cache list is now a
class constant.
Adds BootContext::WebApi (with a BootKernel::defaults arm for match
exhaustiveness) as the state's context; WebApi builds its own two-stage pipeline
rather than going through BootKernel, so no container context/options are set —
preserving the previous behaviour exactly.
Verified in a php+MariaDB dev container: WebApiBootstrap::init('api') populates
$db/$gitRelease/$rSettings, defines PHP_ERRORS/SERVER_ID, and the booted handle
queries the DB.
MagService::getById() and EnigmaService::getById() look up the paired line
with UserRepository::getLineById($rRow['user']['pair_id']), and pair_id is
NULL for a device without a pair. The `int` type the cs-fix pass (758a9cab)
put on getLineById() made that a TypeError, so loading such a device —
deleting it, among others — answered an empty page and changed nothing.
Found by the new E2E device test.
getLineById() is also fed activation codes' nullable subscriber_id and raw
request values, so the guard lives there: anything that is not a positive id
finds nothing, as the untyped version did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
post.php hands its optional `referer` parameter to
AdminHelpers::getPageFromURL() on every edit (streams, movies, created
channels, episodes, lines, MAG, Enigma2, radios, series, resellers). The
new-UI forms post without one, and the `string` type the cs-fix pass
(758a9cab) put on the parameter turned that null into a TypeError: the save
answered an empty 200 and the form showed its error toast. Found by the new
E2E line-edit test.
The function already treated an empty URL as "no page"; it now takes null the
same way, and a URL without a path no longer reads an undefined `path` key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
Replace the fully-static XC_Bootstrap god-class with a stage pipeline so the boot
logic becomes unit-testable and the per-context sequences are explicit.
- BootState replaces the 8 static readiness flags with a value object threaded
through the pipeline; stages read/write it instead of static state.
- BootStageInterface + BootPipeline run an ordered stage list and abort loudly
on a throwing stage.
- 16 stages under Core/Bootstrap/Stage/ hold one subsystem each, extracted
verbatim from the old private methods (constants, config, flood, host, session,
database, legacy core, redis, process title, admin API, translator, admin
shutdown, status constants, admin globals, container populate, health check).
- StageProfiles builds the ordered list per context, mirroring the exact previous
sequence; BootKernel resolves options, sets up the container and runs it.
- XC_Bootstrap is now a thin BC facade delegating to BootKernel; its getters read
the returned BootState. reset() also clears EventDispatcher and the new
DatabaseFactory::reset() (a side-effect-free registry clear for test isolation).
The DB-touching contexts (Cli/Stream/Admin) still require a live MySQL and the
xcvm_core extension, so they are verified on a canary rather than in CI; the
Minimal context and the pipeline/profile composition are covered by new tests.
Introduce the source-of-truth classes for the bootstrap testability refactor.
Purely additive — nothing is wired to them yet.
- ConstantsInitializer: pure value maps (paths/appConfig/binaries/statuses)
plus the single define() site (init/initStatus). The maps evaluate with
different MAIN_HOME/BIN_PATH in one process, which the one-shot define()
constants they feed cannot — this is what makes them testable.
- ErrorResponder: the generateError()/generate404() logic extracted into pure
codes()/renderDebug()/render404()/respond*() plus a single side-effecting
emit(). A test-mode toggle throws ErrorResponseException instead of exit().
- ErrorResponseException: value carrier for a resolved error response.
OPENSSL_EXTRA is now sourced per-install via ConfigReader with a mandatory
fallback to the historical literal, so existing installs (whose persisted data
derives from it) keep decrypting; generation-at-install is left to the installer.
Verified byte-for-byte against the legacy prelude before wiring: 53/53 constants,
debug/404 HTML frozen as sha256 goldens, 65 error codes.
ChannelService (the video-channel twin of RadioService) was among the
most Rector-churned untested classes: the pass inverted many empty-else
branches across its server-tree, transcode and flag handling. Lock that
behaviour with characterization tests over the fully-runnable paths
(massEdit has no verifyPostTable/exit; StreamProcess::updateStreams is a
no-op with cache off), exercised through the dual-backend TestDb:
- transcode flag derivation (c_transcode_profile_id -> enable_transcode
1/0 by sign),
- boolean flag columns (checked => 1, edited-but-absent => 0),
- server-tree ADD (root '#' skipped, 'source' parent stored as NULL),
- setOrder writes a sequential order by the posted id list.
726 tests green.
TestDb can now run the DB-touching unit tests against a real MariaDB as
well as the default in-memory SQLite, so the suite can be exercised on
the panel host (which ships pdo_mysql, not pdo_sqlite). When
XCVM_TEST_DB_DSN is set it connects there and translates the SQLite test
DDL on the fly: AUTOINCREMENT -> AUTO_INCREMENT, a bare INTEGER PRIMARY
KEY gains AUTO_INCREMENT, and each CREATE TABLE is preceded by DROP TABLE
IF EXISTS (a MariaDB schema persists across the per-test connections that
:memory: starts fresh). DDL is routed through exec() on both backends so
the ModuleMigrator path (which runs DDL via query()) works too, and the
MySQL session uses a permissive sql_mode to match SQLite's leniency.
AuthRepositoryTest back-quotes the reserved column `key`.
Three env-fragile guards are tagged #[Group('skip-on-panel')] so the
deployed-panel run can exclude them (--exclude-group skip-on-panel):
ArchitectureTest and StreamTokenCallSitesTest scan the repo src/ tree
(absent / polluted in a flat deploy; they also self-skip when it is
missing), and LoginSessionFixationTest runs in isolated child processes
that the panel's ionCube/OPcache PHP cannot reconstitute.
Verified green on all three backends: SQLite (local, 721), MariaDB 11.4
(container, 721), and the panel's bundled PHP 8.1 + server MariaDB
(713, with the group excluded).
Reviewed Rector batch (3 files) + a manual decomposition of massEdit on top.
Rector batch (reviewed against suite + PHPStan):
- FIX (recurring Rector bug): the boolean inversion again dropped the parens on
an assignment-in-condition in massEdit (array_search category DEL) — restored.
- massEdit: many correct empty-if/else -> guard inversions (verified).
- strlen(x) > 0 -> x !== '' in saveStreamOptions, TMDbService::buildUrl and
ResellerTableRenderer (behaviour-preserving).
massEdit decomposition (166 -> 96 lines, nesting ~10 -> 7), test-first:
- computeCategoryChange() — ADD union / DEL remove / SET replace (pure)
- planBouquetChanges() — SET/ADD/DEL bouquet attach/detach plan (pure)
- planServerTreeForStream()— per-stream streams_servers reconcile (update in
place / batch-insert buffer / delete marking)
- raiseTimeLimits() — the set_time_limit + ini_set block, shared with
massDelete (de-duplicated)
Tests: RadioServiceTest gains computeCategoryChange (5) + planBouquetChanges (3);
new RadioServiceMassEditTest characterizes massEdit end to end (invalid input,
and the server-tree ADD path) — it has no verifyPostTable/exit and
StreamProcess::updateStreams is a no-op with caching off, so the whole method
runs against the SQLite harness. That test guarded the server_tree extraction.
PHPStan level 5 clean; suite 721 tests / 0 errors.
process() was a 250-line, ~10-deep method riddled with the empty-if/else
anti-pattern — the exact shape Rector mis-transformed (which is why RadioService
was reverted from the Rector pass). Rebuild it safely, test-first:
Extracted 7 helpers, each covered by tests (RadioServiceTest, 18 tests):
- buildAutoRestart() — auto_restart schedule from days/time
- resolveSelectedIds() — created-name or numeric id (merged the duplicated
bouquet/category resolution)
- createMissingBouquets() — insert bouquets from bouquet_create_list
- createMissingCategories() — insert radio categories from category_create_list
- saveStreamOptions() — clear + re-insert streams_options (6 option types)
- syncServerTree() — reconcile streams_servers against the server tree
- syncBouquets() — attach to selected bouquets, detach on edit
Flattened the orchestrator: 250 -> 87 lines, nesting ~10 -> 4. Guard clauses for
validate / auth / no-source, ternaries for the flag fields, and the vestigial
single-element $rImportStreams loop removed (mutating $rArray directly is
equivalent to its per-iteration merge). exit() on auth failure is unchanged
(returning instead would be a semantic change). Behaviour preserved throughout.
RadioServiceProcessTest characterizes the outer guard flow (INVALID_INPUT, and
NO_SOURCES via the edit path) — the branches the flattening restructures. The
success path can't run under the SQLite harness (verifyPostTable hits MySQL
information_schema) so its internals are covered by the extracted-helper tests.
PHPStan level 5 clean; suite 711 tests / 0 errors (+20).
Ran `make rector-fix` (deadCode + codeQuality prepared sets) over the PSR-4
class trees (Core/Domain/Cli/Infrastructure). Net -600 lines: dead-code removal
and the empty-if/else collapse (the feedback_simplify_empty_else pattern this
adoption targeted). 99 files mechanically transformed.
The output was reviewed against the full test suite + PHPStan + a targeted scan;
this commit is the CORRECTED pass (no broken state in history):
- FIX (Rector bug): its boolean inversion dropped the parens around an
assignment-in-condition — `if (($rKey = array_search(...)) === false)` became
`if ($rKey = array_search(...) !== false)`, assigning the bool to $rKey and
unsetting the wrong array offset. Restored parens in 7 sites (BouquetService,
ChannelService, CategoryService). PHPStan caught only 1 of the 7; the rest
were silent. Added BouquetServiceTest as a regression (proven to fail on the
broken form).
- FIX (pre-existing, same class): GroupService::removeGroupFromUsers had the
identical dropped-parens bug already in the tree — corrected here too.
- FIX: ServersCronJob::pingServer returns floor() (float) under an `: int`
return type — added an (int) cast.
- ACCEPT: LocallyCalledStaticMethodToNonStaticRector converted 25 locally-called
private static helpers to instance methods (behaviour-preserving; call sites
rewritten). Updated MonitorCommandTest's reflection helper to invoke on a
constructor-less instance.
Verified: PHPStan level 5 clean (0), suite 691 tests / 0 errors.
NOTE: do not re-run `make rector-fix` on this tree without first skipping the
inversion rule — the dropped-parens bug is deterministic and would return.
Admin-API boot runs ResellerAPI::init() on the login page (pre-auth), where the
$rPermissions global is still null. It passes that null straight into
ServerRepository::getStreamingSimple()/getProxySimple(), whose strict
`array $rPermissions` hint turned it into a fatal TypeError at boot:
ServerRepository::getStreamingSimple(): Argument #1 ($rPermissions) must be of
type array, null given, called in .../ResellerAPI.php on line 72
Both methods only read $rPermissions via isset($rPermissions['is_reseller']), so
null is functionally equivalent to "no reseller restriction". Make the param
`?array $rPermissions = null` on both (restoring pre-typing tolerance) rather
than patching all ~28 call sites that feed the global. The strict `array` type
stays on every other repository method that runs post-auth with a real array.
Add ServerRepositorySimpleTest as a regression (null perms, online filter,
reseller name masking, proxy list) against the SQLite TestDb.
The panel boots the DB with `new DatabaseHandler()` and no arguments, so $host
is null and the real credentials are resolved by the bundled XC_VM extension in
db_connect() — dbhost is never used on that path. A strict
`normalizeHost(string $rHost)` hint turned that normal null into a fatal
TypeError at bootstrap (initDatabase → __construct → normalizeHost), taking down
console.php / cron boot:
Database::normalizeHost(): Argument #1 ($rHost) must be of type string,
null given, called in .../Database.php on line 53
Make normalizeHost accept and pass through null (?string → ?string), restoring
the pre-typing behaviour; the explicit-credentials path (db_explicit_connect)
still passes a real string. Also make the constructor's implicitly-nullable
`string $x = null` params explicitly `?string` (same root cause, no behaviour
change, and avoids the PHP 8.4 implicit-nullable deprecation).
Add DatabaseHostTest as a regression (reflection on a constructor-less instance,
since the class needs the XC_VM extension to instantiate).
Fifth coverage batch, all driven against the in-memory SQLite TestDb:
- ModuleMigratorTest: install runs master database.sql (or falls back to
replaying deltas <= target when there is none); up() applies only the
(from, to] range ascending; uninstall runs database_drop.sql or no-ops; has()
and discover() semver rules (non-semver files ignored, comments stripped);
and failure propagation from a bad statement.
- SettingsRepositoryTest: getAll() JSON/CSV field normalisation (allow_countries,
allowed_stb_types lowercase/trim/blank-drop, bouquet_name spaces, api_ips,
shared_mount_prefixes incl. the legacy-CSV self-heal) and empty-list collapse.
Points CACHE_TMP_PATH at a temp dir for the trailing FileCache write.
- AuthRepositoryTest: access-code and HMAC-key reads (getAllCodes with/without
type filter, getCodeById, getCurrentCode via XC_CODE, getAllHMAC, getHMACById)
and deleteHMAC existing/missing. getGroupPermissions (JSON_CONTAINS) and
deleteCode (nginx config regen) are left for integration.
+20 tests. Suite: 678 tests, 0 errors.
Fourth coverage batch — file-backed cache and i18n, each driven against a
throwaway temp directory:
- FileCacheTest: set/get roundtrip through serialization, miss = false, has(),
idempotent delete, flush, maxAge expiry (backdated mtime), and the path/age
accessors.
- TranslatorTest: English default, cookie language detection with fallback,
available() listing, {token} substitution, guarded setLanguage(), and the
hardened backfill — a key missing from the active language is written back
with the English value, or with itself as a visible placeholder when English
has none either.
+15 tests. Suite: 661 tests, 0 errors.
Third coverage batch — the pure/near-pure cores of the auth layer:
- AuthorizationTest: hasResellerPermissions() flag lookup; check() short-circuit
when identity globals are absent; the 'user'/'line' report-tree scoping via a
real SQLite query (owner/member within self + all_reports); and the 'adv'
permission logic (admin required, super-admin group 1 bypasses the advanced
list, other groups gated by it).
- BruteforceGuardTest: truncateAttempts() drops entries older than the window in
both indexed (reindexed) and associative (keys preserved) shapes; all-recent
and empty inputs. The IO-bound check* methods are left for integration tests.
+12 tests. Suite: 646 tests, 0 errors.
Second coverage batch — config, request state and the XML parser:
- SettingsManagerTest: set/getAll roundtrip, update, and the typed getters.
Locks the deliberate split on a present-null value (get()/getArray() fall back
via ?? while getBool/getInt/getString cast), and getBool()'s PHP truthiness.
- DomainResolverTest: the non-proxied resolve() path — forced vs kept protocol,
https on :443, Host-header port stripping, and the domain_name / server_ip
fallbacks. Captures a latent bug: an https:// prefix in domain_name yields
'https:host' because '/' is stripped before 'https://' in the str_replace
order (documented in the test so a fix updates it deliberately).
- RequestManagerTest: same store shape as SettingsManager, with has() on isset
semantics (present-null reads as absent).
- XmlStringStreamerTest: end-to-end StringWalker (depth-2 children) and
UniqueNode (named element) parsing over an in-memory stream, empty document,
the File "missing path" and UniqueNode "missing option" error paths.
+26 tests. Suite: 634 tests, 0 errors.
Request had no tests despite being the front door for all HTTP input. Built
from injected arrays (no superglobals), the new suite locks:
- GET/POST merge precedence (POST wins), input/get/post/has/all accessors and
their defaults.
- Typed accessors: getInt() coercion, getBool() via FILTER_VALIDATE_BOOLEAN.
- Server-derived helpers: method/isPost/isAjax/uri/userAgent/host (with the
HTTP_HOST -> SERVER_NAME fallback) and their empty-server defaults.
- Client IP resolution: first valid X-Forwarded-For entry, skipping invalid
headers to X-Real-IP, and the 0.0.0.0 sentinel when nothing is usable.
- Security sanitizers (the important part): NUL-byte stripping, ../ traversal
neutralisation, <script>/<!-- --> defusing, stripslashes, CRLF normalisation,
key scrubbing (__x__ / double-dot / htmlspecialchars), and recursive in-place
cleanGlobals().
+13 tests, 57 assertions. Suite: 608 tests, 0 errors.
Two regressions surfaced when running the suite (9 + 3 errors):
- setDb(DatabaseHandler) rejects the SQLite double: the DatabaseAware
refactor gave setDb() a strict DatabaseHandler hint, but TestDb was a
standalone class. Make TestDb extend DatabaseHandler (a real subtype) so
it satisfies the seam; its own constructor wires sqlite::memory: and never
calls the MySQL-connecting parent constructor. Overridden methods widen
parameter types (contravariant) and add return types, so LSP holds.
- validateHMAC(int|string $rExpiry) rejects null: HmacTokenTest passed null
for an intentionally-empty expiry. Pass '' — identical HMAC input, and the
strict type stays correct (the sole production caller always passes a value).
Suite is green again: 580 tests, 0 errors.