Commit Graph
149 Commits
Author SHA1 Message Date
Divarion_D 559d7347c4 feat(reseller): dashboard location map, live connections and recent media
Dashboard half of Rosmi720/XC_VM@8fa1e67f, which 78d7b232 left out over
tenant scoping. Same three panels, built on the endpoints that already
enforce the reseller's scope instead of on fresh SQL in the controller.

- Connections by Location: world map plus a top-countries breakdown, from
  one `lines_activity` query joined onto `lines`. Gated on
  save_closed_connection + dashboard_map and only ships jsvectormap once
  there is something to paint, mirroring the admin dashboard.
- Live Connections: the ./table?id=live_connections serverSide table the
  Live Connections page uses, five rows, kill button included. Rendered
  only with reseller_client_connection_logs.
- Recently Added: Streams / Movies / Episodes tabs over
  ./table?id=streams|movies|episodes, newest first. Rendered only with
  can_view_vod.
- Recent Activity and Expiring Lines become DataTables (sort, search,
  paging) instead of fixed-height scroll boxes.
- Activity rows: text-dark / text-secondary -> text-body /
  text-body-secondary. The Bootstrap 5.3 `text-dark` is near-black, so
  those links were unreadable on the dark skin.
- Tolerate a request that lost its user: the controller and the shell now
  fall back to '' / 0 instead of fataling on $rUserInfo['username'] and
  ['member_group_id'].

Changed on import:
- No unscoped fallbacks. Upstream re-ran the country, movie and live
  stream queries without the tenant filter whenever the scoped one came
  back empty, and never scoped series or episodes at all, so an empty
  reseller saw the whole panel's data. Empty scope now means empty panel,
  which is what ResellerTableRenderer does everywhere else.
- Drop the hardcoded 'EG' fallback for connections GeoIP could not place;
  those rows are left out of the map instead of being pinned to Egypt.
- $rCanVod reads the can_view_vod permission. Upstream set it to a literal
  true, which showed the media panels to resellers without VOD access.
- No CategoryService::getAll() alias and no category-name resolver: the
  table endpoints already return a resolved category per row.
- No stream_view links. That route is registered for admin only, so the
  per-row links and View buttons upstream added would 404 for a reseller.
  (Seven existing reseller views have the same dead link — separate fix.)
- Only the three strings the panels ask for, not all 138 upstream keys;
  the rest are already present or unreferenced.

Verified: 869 tests, make gates, CRAP gate, PHPStan. Visual layout still
wants a browser pass.

Refs: #148
2026-09-22 22:29:06 +03:00
Divarion_D e77698580c fix(reseller): guard the live-connections Redis batch read
Last of the three items Rosmi720/XC_VM@8fa1e67f left over, deferred in
78d7b232 because the CRAP ratchet refuses hardening an uncovered cc=53
method. Extracting the guard instead of inlining it keeps the sniff happy
and gives the batch read a home the other six call sites can move to.

ResellerTableRenderer::handleLiveConnections iterated
RedisManager::instance()->mGet($rKeys) directly. RedisManager::instance()
returns null when Redis is unreachable, which makes that a fatal; mGet
answers false on a dropped connection and the client itself while
pipelining, which makes the foreach a warning; and a reseller with no
lines sends an empty key batch. Admin\TableController already carries the
guard — this moves it onto ConnectionTracker::readConnections(), which
already owns the LINE# reads two lines above.

Also swap the `array_multisort` order-column test to !empty(): the client
picks the column by index, so it can name the Responsive control column
(index 0, `false`) or one past the end of the map, and array_multisort on
an undefined key is a TypeError.

Verified: 869 tests, make gates, CRAP gate, PHPStan.

Refs: #148
2026-09-22 22:28:46 +03:00
Divarion_D aab782b9c2 fix(modules): stop a reinstall from switching a disabled module back on
installModule() ended with an unconditional setState(Enabled). Store updates,
zip uploads and plain reinstalls all pass through it, so switching a module off
in the panel survived only until its next update — which is how a disabled plex
came back and then blocked `watch` from being disabled at all.

The admin's choice is now captured before the Installing transition overwrites
it and restored at the end; a first install still enables, since there is no
choice to preserve. stateAfterInstall() reads the raw override rather than
listModules() so the transient Installing / Failed states cannot be mistaken
for a deliberate "disabled".

Restoring that state uses the new writeState() instead of setState(): putting a
module back into the state it already had is a lifecycle transition, not an
admin "disable", and must not be vetoed by the dependents guard — otherwise
updating a disabled `watch` would fail at the last step because `plex` exists.
setState() keeps the guard for the deliberate case.

Three tests cover it; two of them fail against the previous behaviour.

Verified: 860 tests, make gates, CRAP gate.
2026-09-22 20:56:43 +03:00
Divarion_D 489555f039 refactor: apply the Rector stage-2 pass and keep its helpers reachable from tests
Mechanical output of `make rector-fix` over the class-based trees, reviewed
change by change. Most of it is empty()/=== [] normalisation, a chained === turned
into in_array(..., true) in FanoutConfig, and multi-line condition reformatting.

Three changes were checked more closely because they looked behaviour-affecting:

- Translator::isRtl() loses its (string) cast on $lang. Safe: $currentLang is a
  non-nullable `private static string`, so the ??= above always leaves a string.
- TableController loses htmlValue() and jsArgument(). They are escaping helpers,
  so the removal was verified rather than trusted: neither has a single reference
  anywhere under src/, views included. Genuinely dead.
- StartupCommand and StreamsCronJob have three private helpers turned from static
  into instance methods (LocallyCalledStaticMethodToNonStatic).

That last one broke 7 tests, which reached the helpers through Reflection and
invoked them statically. Fixed in the tests rather than by reverting the rule —
reverting would only lose the argument again on the next `make rector-fix`. The
Reflection helpers now bind an instance only when the method needs one, so they
survive the flip in either direction.

Also re-ran the two KNOWN BUG greps from build/rector.php (the rules that drop
parens around an assignment-in-condition and invert the guard): both clean.

Verified: 857 tests, make gates, make cs, CRAP gate.
2026-09-22 18:19:45 +03:00
AbdoAhmedElbanaaandDivarion_D 5c6f9f920c fix(tickets): overhaul ticket lifecycle, visibility and admin interactions
Imported from Rosmi720/XC_VM@260ca7ab, with the scoping and permission gaps
closed before merge.

Original work:
- Keep tickets listed through a LEFT JOIN so one does not vanish when its
  author's user row is edited or deleted; username falls back to 'Unknown'.
- Stop double-escaping message bodies: the repository returned
  htmlspecialchars() output that both ticket_view templates escape again, so
  markup showed up as entities. The repository now returns raw text and the
  views keep their nl2br(htmlspecialchars()).
- Drop the str_repeat('  ', ...) padding hack that rendered literal
  ' ' inside short replies.
- Resolve last_reply and the derived status flags without undefined-key
  warnings on tickets that have no replies yet.
- Add Close / Re-Open to the admin ticket view with a confirmation dialog, a
  quick reply form under the thread, and a Reply entry in the tickets table.
- Add English and Arabic strings for the confirmation dialogs.

Changed on import:
- Tenant isolation: the original widened the admin branch to every ticket on
  the server whenever $rAdmin was set, and admin/tickets.php always sets it.
  Since users_groups.is_admin is a flag several groups can carry, that exposed
  every tenant's tickets to any admin-panel user. Only the super-admin group
  (member_group_id = 1) now gets the unscoped list; any other admin stays
  scoped to the users it owns. TicketVisibilityTest covers all three scopes.
- Gate Close / Re-Open, the quick reply form and the Reply links behind the
  same Authorization::check('adv', 'ticket') the tickets table already used.
- Quote 'Unknown' as a string literal, not "Unknown", which ANSI_QUOTES would
  read as an identifier.
- Escape the textarea placeholder.
- Drop the admin/functions.php hunk: that file no longer exists after the PSR-4
  move and AdminApiStage already populates $GLOBALS['rAdminUserInfo']. The
  post.php fallback is kept, since post.php can be served standalone.

Verified: 857 tests, make gates, CRAP gate.
2026-09-22 16:32:39 +03:00
Divarion_D adb36d8a5b fix(modules): unblock disabling, contain lifecycle failures, keep one dir per module
Disabling a module whose own dependency was already off was impossible: the
guard counted a dependent's nominal Enabled state, while ModuleLoader prunes
unsatisfiable modules transitively. A dependent sitting on an already-broken
chain is not running, so it must not block. The guard now mirrors the loader's
prune and only counts dependents that would genuinely stop working.

Alongside that, three lifecycle holes found while reviewing ModuleManager:

- updateModule() had no failure handling and no incremental watermark. A
  migration failing half-way left the module Enabled at the old version, so a
  retry replayed already-applied deltas — a non-idempotent one then failed
  forever. File deltas and programmatic migrations are now merged into one
  ascending timeline, the recorded version advances per COMPLETED version, and
  a failure marks the module Failed. ModuleMigrator gains pending() and a
  public applyFile(); up() is reimplemented on top of them, unchanged.

- uninstallModule() had no try/catch, unlike installModule(). A failing
  uninstall() hook left the module installed and Enabled with half-deleted
  data. It now marks the module Failed. The duplicated dependents guard moves
  into assertNoInstalledDependents(), shared with deleteModule().

- Platform (store) installs assumed a bare modules/{slug} directory while every
  other path uses {name}_{hash5}. The backup step then found nothing and the
  pulled copy landed beside the existing install, leaving two manifests for one
  module. The platform flow now reuses placeModuleFiles(), which drops rival
  copies and moves the source into place instead of copying it; listModules()
  de-duplicates by name so a stray copy cannot show the module twice.

archivesPath now sits beside modulesPath rather than resolving under MAIN_HOME,
so an injected modules path is honoured (identical path in production).

Verified: 852 tests, make gates, CRAP gate.
2026-09-22 16:21:51 +03:00
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