Commit Graph
143 Commits
Author SHA1 Message Date
AbdoAhmedElbanaaandDivarion_D 0cf24ad075 feat(i18n): right-to-left (RTL) UI support and Arabic translation
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.
2026-09-21 21:32:19 +03:00
AbdoAhmedElbanaaandDivarion_D 060ded9698 fix(core,streaming): preserve execution permissions for core scripts and redis self-healing on boot
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.
2026-09-21 19:44:23 +03:00
AbdoAhmedElbanaaandDivarion_D 202ad85d6c fix(cron): resolve TypeError in StreamsCronJob when parsing Redis connections
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.
2026-09-21 19:17:33 +03:00
AbdoAhmedElbanaaandDivarion_D cf471013b9 feat(stream): implement Encrypt Playlists across live and VOD
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.
2026-09-21 19:11:10 +03:00
AbdoAhmedElbanaaandDivarion_D 724a412d37 fix(streaming): safeguard GeoIP reader and HOST constant against live white screen
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.
2026-09-21 19:03:39 +03:00
AbdoAhmedElbanaaandDivarion_D e264443909 fix(player-api): tolerate null bouquets and stream icon during player login
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).
2026-09-21 18:55:17 +03:00
Divarion_D a403b8b6af feat(category-templates): apply templates on line/mag/enigma & reseller vouchers
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.
2026-09-21 18:37:33 +03:00
Divarion_D 63210f8f7c feat(category-templates): show templates shared by any ancestor reseller
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).
2026-09-20 20:40:55 +03:00
Divarion_D c289352d43 feat(build): per-build watermark (RELEASE_ID / XC_VM_BUILD_ID)
`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.
2026-09-18 15:45:34 +03:00
Divarion_D 5ef12b1cde feat(license): fanout licence gate seam (soft, fail-open)
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.
2026-09-18 15:32:50 +03:00
Divarion_D bd54afa16b feat(integrity): lock panel UI when AGPL attribution is removed
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.
2026-09-18 15:10:43 +03:00
obscuremindandGitHub c29e163a7a Merge branch 'main' into teste 2026-09-18 11:19:11 +01:00
rootandobscuremind 469a8a8d64 test: add bouquet and admin playback coverage 2026-09-18 09:57:17 +00:00
9252836b05 fix(player-api): a channel without an icon took down the whole listing
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
2026-09-18 09:57:17 +00:00
a8bec79d21 fix(player-api): sign-in crashed with a TypeError before answering
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
2026-09-18 09:57:17 +00:00
Divarion_D c21e786bed Merge branch 'main' of https://github.com/Vateron-Media/XC_VM 2026-09-18 11:39:08 +03:00
Divarion_D 2c7ea749e7 refactor(admin): delete functions.php, consolidate the admin bootstrap
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.
2026-09-18 11:32:15 +03:00
rootandClaude Opus 4.8 73f85d48a0 feat(fanout): toggle the daemon's debug narration from the panel
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>
2026-09-18 07:40:43 +00:00
rootandClaude Opus 5 1b86ba3670 feat(fanout): surface the daemon's memory, and let the panel drain a node
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>
2026-09-18 00:45:37 +00:00
rootandClaude Opus 5 a50f42d955 fix(security): stop a movie without running shell commands
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
2026-09-17 08:41:26 +00:00
rootandClaude Opus 5 e52c451e3c fix(security): resellers generate activation codes on their own terms only
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
2026-09-17 08:07:02 +00:00
rootandClaude Opus 5 62e7ea55cd fix: three crashes from null values and a missing cache file
- 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
2026-09-17 07:51:18 +00:00
rootandClaude Opus 5 73026b0eb1 test(constants): stop pinning the release version
"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
2026-09-17 07:48:24 +00:00
rootandClaude Opus 5 c63add1801 fix(security): enforce admin and reseller page permissions again
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
2026-09-17 07:48:24 +00:00
rootandClaude Opus 5 0d06fff983 fix(security): confirmIDs returns the integers it checked
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
2026-09-17 07:45:48 +00:00
rootandClaude Opus 5 99036f99d0 fix(security): resellers can no longer pair onto or re-trial other lines
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
2026-09-17 07:45:14 +00:00
rootandClaude Opus 5 760ad8a371 fix(security): keep post-login redirects on the panel
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
2026-09-17 07:42:42 +00:00
DanilandGitHub 2b3ae3dc53 Merge pull request #195 from obscuremind/main
tests(e2e)
2026-09-16 19:04:38 +03:00
Divarion_D 4ea916ab89 fix(bootstrap): address Sourcery review — WebApi profile guard, falsy http code, getBool
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.
2026-09-16 18:57:54 +03:00
Divarion_D 7064f41fed test(bootstrap): cover stage skip-paths and side-effect stages
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).
2026-09-16 18:38:31 +03:00
rootandClaude Opus 5 ee2f586a44 fix(streams): adding a stream without an icon failed
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
2026-09-16 14:38:17 +00:00
Divarion_D 6ab7e63ca1 refactor(bootstrap): route WebApiBootstrap DB init through the shared stages
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.
2026-09-16 17:23:33 +03:00
rootandClaude Opus 5 fd13c94032 fix(devices): deleting an unpaired MAG or Enigma2 device failed
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
2026-09-16 14:18:09 +00:00
rootandClaude Opus 5 f7bba5cbcd fix(admin): saving an edit from the new UI answered an empty page
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
2026-09-16 14:18:09 +00:00
Divarion_D e1bb80672e refactor(bootstrap): split XC_Bootstrap into an injectable BootKernel pipeline
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.
2026-09-16 16:58:49 +03:00
Divarion_D 6a39902fbf refactor(bootstrap): add ConstantsInitializer + ErrorResponder PSR-4 classes
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.
2026-09-16 16:34:02 +03:00
Divarion_D 83aed2e0be test(channel): cover ChannelService massEdit / setOrder branches
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.
2026-09-13 21:21:09 +03:00
Divarion_D 52d801acb2 test: dual-backend TestDb (SQLite + MariaDB) + skip-on-panel guards
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).
2026-09-13 21:17:06 +03:00
Divarion_D b9546dc2c8 refactor(radio): third Rector batch (reviewed) + decompose massEdit
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.
2026-09-13 18:57:54 +03:00
Divarion_D 7aa7b0d29d refactor(radio): decompose RadioService::process into tested helpers + flatten
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).
2026-09-13 18:33:02 +03:00
Divarion_D dc7fa83b0a refactor: apply Rector safe sets + fix the transform's fallout (reviewed)
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.
2026-09-13 17:27:42 +03:00
Divarion_D a788a450a5 fix(server): accept null permissions in getStreamingSimple/getProxySimple
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.
2026-09-13 16:10:40 +03:00
Divarion_D 284798ede7 fix(db): let Database accept a null host again (CLI boot crash)
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).
2026-09-13 16:05:56 +03:00
Divarion_D 6964e99f35 test(core): cover the DB layer — ModuleMigrator, SettingsRepository, AuthRepository
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.
2026-09-13 14:51:45 +03:00
Divarion_D c370dc47fc test(core): cover FileCache and the Translator backfill
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.
2026-09-13 14:45:12 +03:00
Divarion_D a1228039fe test(core): cover Authorization checks and BruteforceGuard flood truncation
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.
2026-09-13 14:42:08 +03:00
Divarion_D d3555ebb20 test(core): cover SettingsManager, DomainResolver, RequestManager, XML streamer
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.
2026-09-13 14:39:43 +03:00
Divarion_D 6d3cf2b25e test(core): cover the Http\Request input abstraction and sanitizers
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.
2026-09-13 14:29:54 +03:00
Divarion_D 81b66809dd test(core): cover UpdateChannels and the module extension registries
First batch of the src/Core coverage effort — pure in-memory / settings-driven
classes that had zero tests:

- UpdateChannelsTest: per-repo channel resolution (main/bin/fanout read their
  own update_channel_* setting), the 'unstable'->'beta' legacy alias, the
  unknown/empty -> 'stable' fallback, and forRepo() dispatch (BIN/FANOUT map to
  their channel; MAIN/UPDATE/PROXY/unknown follow MAIN). Seeded via
  SettingsManager::set().

- ModuleRegistriesTest: TableRegistry (register/lookup/last-wins), TopbarRegistry
  (order sort, per-label last-wins, export-page + log-type flags), QuickToolsRegistry
  (group rows, keys, handler lookup) and PermissionRegistry (ordered deduped set).

+15 tests, 41 assertions. Suite: 595 tests, 0 errors.
2026-09-13 14:26:46 +03:00
Divarion_D c1dd191d1b test: repair the SQLite test harness broken by strict typing
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.
2026-09-13 14:22:55 +03:00