The first cut of the Recently Added card was too bare next to the rest of
the shell. Same three endpoints, more to look at.
- Tabs carry a type icon and a live count badge, filled from the
endpoint's recordsTotal on the first draw rather than hardcoded.
- Rows gain an Added date and a tinted type badge where a stream has no
artwork, so the poster column never collapses to an empty cell; the
category becomes an accented chip and the connections badge turns green
when someone is watching.
- Card headers (location map, live connections, recent activity, expiring
lines) get their accent icon.
`added` is new in the streams / movies / episodes table payloads — the
column was already on `streams`, it just was not selected. It is not
sortable: ResellerTableRenderer maps the sort column by DataTables index
and only knows id, title and category, so the columns appended after them
are explicitly orderable: false.
The Action button opens the stream's live connections instead of a stream
detail page, and only with reseller_client_connection_logs. `stream_view`
is registered for admin only, so an eye button pointing at it would 404
for every reseller.
Verified: 869 tests, make gates, CRAP gate. Visual layout still wants a
browser pass.
Refs: #148
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
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
`the channel is listed under its category` failed intermittently on the second
poll: the category never showed up in get_live_categories within 200s.
That list is filtered by the line's category_ids, which UserRepository builds
from the `category_map` cache file, and only CacheCronJob's heavy pass writes
it. That pass returns early while its `heavy_cache_built` marker is under 300s
old, and cron:cache runs once a minute, so a category created just after a pass
waits up to ~360s. A 200s budget cannot cover that — the test passed only when
the category happened to land late in a cycle, which is why it looked flaky
rather than broken.
The poll now gets 380s and the test 600s, with the 300s throttle named in the
comment so the budget is not trimmed back without seeing why it is that size.
Nothing in the panel changed; the first poll (get_live_streams, fed by the
light pass) was always passing.
Forcing the rebuild from the test is not an option: ./api?action=regenerate_cache
runs cron:cache_engine, which only reads bouquet_map, and the only way to skip
the throttle is `console.php cron:cache 1` — a shell the HTTP-only harness does
not have.
Verified: the full spec file passes (10/10, 3.5m).
Hiding the Actions column behind the permission was not enough: a reseller who
holds reseller_client_connection_logs — the default for the Resellers group —
still got a full-width column that is empty on every row until someone is
actually watching, which is most of the time.
The column is gone from all five stream tables. Kill Connections now sits next
to the connections badge, as a hammer button matching the one on the Live
Connections page, and appears on exactly the rows where it can do something:
a non-zero viewer count and the permission to purge it. Nothing is lost — the
badge still links through to the per-connection list — and the table no longer
carries a column that renders nothing on an idle panel.
$rCanKill now only picks the badge variant, so the view has no PHP branching
left in its markup; header and column counts stay at six either way.
Verified by rendering each view's connections cell for 0 and 3 viewers with
and without the permission.
Streams, movies, radios, episodes and created channels each have exactly one
row action — Kill Connections — and it renders only for a reseller holding
reseller_client_connection_logs. Without that permission the column was a
header over permanently empty cells, which reads as a broken page rather than
a deliberate restriction.
The header and its column definition are now emitted only when the permission
is held, so the table ends at Connections. The per-row guard keeps the
row.clients > 0 half — a stream with no viewers still has nothing to kill —
and loses the canKill half, which the column's existence now implies.
The column count and columnDefs targets were verified to match the rendered
<th> count in both permission states.
The page was the only admin screen still POSTing to itself and rendering its
own rows server-side, so every action reloaded the whole page and the row
markup lived in a second place.
It now follows the standard: ./table?id=modules feeds the grid and each row
action goes to ./api?action=module, handled by the new ModuleAjaxController
behind the same adv/settings permission the page requires. Search and paging
run in PHP over listModules(), since modules come from the filesystem and
config rather than a query.
The outcome of an action is read back off the reloaded table, never inferred
from the request: the dependents guard can refuse a disable, and toggling one
module changes the dependency warnings shown on the others. An install runs
migrations, so the button shows "wait", stays disabled and the table is polled
every 2s for up to 30s; a refusal reports immediately instead of waiting out
the timeout, and a row that vanished settles rather than hanging.
The view drops jQuery and DataTables for fetch and plain DOM — it was the only
consumer of either here, and the <script> block sat before the footer that
loads jQuery, which broke the page outright with "jQuery is not defined". The
block now sits after renderUnifiedLayoutFooter('admin'), matching the other
admin views. Notifications no longer scrollIntoView, so acting on a row near
the bottom no longer yanks the page up.
The dev panel runs in a container with no sshd, so every rsync at it died with
"Connection refused" and the fix had to be docker cp'd by hand.
Transport is now behind remotecmd/remoteopen: ssh+rsync as before, or docker
exec + docker cp when the target is a container. Pass --container=NAME or set
DEV_CONTAINER; a target that matches a running container's name or IP is
detected without either.
Two bugs kept servers on a stale extension.
The installer pointed at the old release path and kept its own sidecar marker
file recording "the version we installed". The marker survived a manual .so
swap, a rollback and a failed install, so the command compared the wanted
version against a number nothing guaranteed and concluded there was nothing to
do. phpversion('xcvm_core') asks the loaded extension itself, which cannot
drift, and lets the marker go.
Candidate selection also fell back to any build in SHA256SUMS when the exact
php8.x group was missing. That happily installed a module built for another
ABI, which then fails to load. groupCandidates() now returns the wanted group
or nothing, so a host with no matching build gets a clear failure instead of a
broken extension.
The settings page reads the same phpversion() value, so the panel and the CLI
agree on what is installed.
The dashboard banner asked whether an activation key file exists on disk, so
any string written into it — expired, issued for a different HWID, or simply
wrong — made the warning disappear while the panel stayed locked. Deleting the
key on a live server produced no warning either, because the file check ran
against a stale path.
It now asks LicenseGate::licensed(), which is the same verdict the panel acts
on, so the banner and the actual lock can no longer disagree.
The save endpoint re-checks right after writing and reports a rejected key
instead of the unconditional "Activation key saved." The file is deliberately
left in place: the extension may cache its verdict for the request, so a
rejection here is not proof the key is bad, and silently deleting what the
admin just pasted would lose it.
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.
Rector — enable instanceOf, earlyReturn and if. Each was measured at zero changes
across the analysed tree, so they cost nothing now and only hold the line on new
code. The counts for every set that stays off are recorded next to them, with the
reason, so the decision does not have to be re-derived: typeDeclarations(319) is
the native-type footgun TYPE_AUDIT.md tracks, typeDeclarationDocblocks(86) feeds
that same footgun one step removed because `make cs-fix` derives native types from
docblocks, codingStyle(255) rewrites the `use` imports check-procedural-use depends
on, naming(132) fights the $r-prefix convention and render()-by-name views, and
privatization reads as 0 only because there are almost no final classes yet.
Makefile — `make rector` now passes --clear-cache. Rector's cache key does not
track the rule set, so after editing the config a cached run prints "Rector is
done!" and a newly enabled set looks like a no-op. The measurements above first
came back all-zero for exactly this reason.
phpcs — silence the four sniffs that were still reporting. Every one of them was
checked for a fixer first (none calls addFixable), so `make cs-fix` could never
have cleared them; what was left was 769 warnings no tool can act on:
NoSilencedErrors(473) each @ is either deliberate or masking a real error, so
they need judging one at a time, with a test each
CyclomaticComplexity (201 err + 190 warn) and NestingLevel (2 + 84) — already
ratcheted per-function by the CRAP gate, which blocks NEW
complexity; the sniffs only restated the old backlog
PSR1.Files.SideEffects(22) needs files split, which moves loading and autoload
LineLength(3480) went the same way earlier and is marked as not part of the
ratchet: it has no fixer at all, so "re-enable and fix" would mean splitting those
lines by hand.
Own rules are commented out so they read as toggles; the PSR12-inherited ones
(SideEffects, LineLength) can only be <exclude>d, which the ratchet summary now
says out loud. That summary is updated with every new toggle and its count.
`make cs` is now clean, with 85 sniffs still active — checked, because an empty
report looks the same whether nothing is wrong or everything got muted.
PSR-12 wants it spelled out; phpcs reported all nine under
PSR12.Properties.ConstantVisibility.
All are `public`, which is exactly PHP's implicit default, so nothing changes at
runtime. Narrowing any of them would have been an API change wearing a lint fix as
a disguise: eight are referenced from outside their class (XC_Bootstrap::CONTEXT_ADMIN
ten times), and the CONTEXT_* four are explicitly @deprecated back-compat surface
for module code, so their whole purpose is to stay reachable.
One oddity left alone: StreamProcess::REMUX_EXIT_UNSUPPORTED has no references at
all, inside the class or out. It documents an exit status of `xc_fanout remux`
(supervisor.ExitUnsupported), so it reads as a protocol constant rather than debris
— worth confirming against the daemon before anyone deletes it.
Verified: 857 tests, make gates, make cs.
Clears the remaining 11 occurrences, finishing what the MonitorCommand untangle
(62 -> 8) started. `Generic.PHP.DiscourageGoto` stays enabled and now guards zero.
ImageResizeService — `fallback:` was just the tail of the method (placeholder, else
a 1x1 transparent PNG, then exit), reached by two jumps and by falling through.
- The first jump was redundant. Dropping it leaves $rImage null, no cache file is
written, and control arrives at the same tail on its own. The guard becomes
!empty($rawImageData), which also matters without the jump: the old `!== ''`
let null through to imagecreatefromstring() and PHP 8 deprecates that.
- The second became an elseif: decoded -> resize, otherwise if the bytes are WebP
-> pass them through for the browser to decode. That is what the branch always
meant; the jump only hid it.
Net one branch fewer and no new method — deliberately, since extracting the tail
would add an uncovered helper the CRAP ratchet rejects, and this code cannot be
unit-tested as written (it emits headers and exits).
Ministra — all three sites in PortalHandler had the label immediately after a
foreach, so the jump was a plain `break`, and the deobfuscator had left `//break;`
next to each one. Restored as written: removing a favourite movie, removing a
favourite series, finding the current EPG entry.
portal.php was different: the label sat inside the block, after the assignment, so
the jump skipped adding a series to the output. Around it were two empty ifs with
non-empty elses and a $rFound flag standing in for one expression. All of it folds
into the condition it always was — without a favourites filter every series passes,
with one only those the device favourited.
Ministra runs on MAG set-top boxes and cannot be exercised here; the rewrites are
equivalent by inspection, so favourites and the EPG page deserve a look on a real
device before release.
Verified: 857 tests, make gates, make cs, CRAP gate.
ProxyController and PackageEditController each carried the full country list as a
single inline array literal — 10078 and 10042 characters on one line — and passed
it to the view as $rCountries. Both are byte-for-byte copies of data GeoReference
already owns: the proxy list equals geoCountries() ("All Countries" first) and the
package list equals countries() ("Off" first), 249 rows each.
The views now read the reference directly, the same way admin/settings.php already
did, and the controllers stop passing the data through. Longest line in those two
controllers drops to 86 and 63 characters.
Equivalence was checked rather than assumed: for each pair the old literal was
recovered from HEAD and both versions were rendered to <option> markup, including
the selected branch. 249 options each, identical.
Verified: 857 tests, make gates, make cs.
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.
PHPStan flagged @param array|null $rCategoryMap against the native array type.
The docblock predates the caller, which already normalises with
is_array($rCategoryMap) ? $rCategoryMap : [] — null never reaches the method, so
the native type is the accurate one and the docblock was simply stale.
Narrowing the docblock rather than widening the signature also keeps `make
cs-fix` honest: it derives native parameter types from these tags, so a docblock
claiming a nullability the code does not have is how a wrong hint gets written
into a signature.
A sweep of src/ (excluding views and vendor) finds no other @param that admits
null against a non-nullable native type.
Sidebar half of Rosmi720/XC_VM@8fa1e67f, with its permission regression fixed.
- Group the menu into captioned sections (Clients, Content, Category Templates,
Tickets & Logs) instead of one flat list. A caption renders only when at least
one of its items survives the permission gates, so a reseller without, say,
any client permissions never sees an empty "Clients" heading.
- Drop a node whose children were all hidden by permissions and whose own url is
'#': it used to render as a toggle that expanded to nothing.
- Fix active-item highlighting in two cases the plain basename match got wrong.
`line` and `line?trial=1` share a basename, so both lit up at once; the trial
entry now matches only while ?trial=1 is set and the plain one only while it
is not. Detail routes (`ticket_view` under Tickets, `category_template` under
Category Templates) left the sidebar with nothing highlighted, so an alias map
folds them back onto their section.
- Category Templates was listed twice (top level and again under Content); it
now has one entry, in its own section.
Changed on import:
- Keep LineService::canGenerateTrials() gating the three Generate Trial entries.
Upstream replaced it with a plain create_line check, which would offer trial
generation to resellers that may create lines but not trials.
- Drop the flat $xmMenu compatibility shim — nothing outside this file read it.
Also add three strings the sidebar asks for. A key with no en.ini entry makes
Translator return the key itself AND append it to the ini, so `sub_resellers`
was rendering raw in every language except Arabic; since resolveMissingKey()
falls back to the English value, the one entry fixes all of them.
Verified: 857 tests, make gates, every menu label and section title resolves to
a real translation. Visual layout still wants a browser pass.
Safe subset of Rosmi720/XC_VM@8fa1e67f. The dashboard revamp and the sidebar
restructure from that commit are deliberately left out — see below.
- The sidebar's existing `?trial=1` links on Lines / MAG / Enigma now actually
land on the Trial filter: each page picks the trial value from its own
$rStatusFilters map (5 for lines, 4 for MAG and Enigma) when no explicit
?filter is given.
- Restyle the voucher credit-accounting card in active_code.php onto standard
Vuexy surface classes instead of a hand-tinted primary panel. Element ids and
the calculation are untouched, so the existing calculator JS still drives it.
- RequestGuard: fall back to '' for HTTP_HOST. A request can legitimately arrive
without a Host header, and explode(':', null) is deprecated on PHP 8.
- Add the live_calculator string the new card asks for. Without it Translator
returns the key itself AND appends it to the ini, so the card would render the
literal "live_calculator" (which is how the placeholder appeared upstream).
The Arabic wording is a first pass and welcomes a correction from its author.
Left out of this commit:
- ResellerDashboardController / dashboard.php: three fallbacks drop tenant
scoping when the scoped query comes back empty — top countries re-queries
lines_activity server-wide, latest movies and live streams re-run without the
stream_ids restriction, and series and episodes are never scoped at all. It
also pins unknown connections to a hardcoded 'EG' country, and moves ~10 raw
SQL queries into a controller.
- The sidebar restructure: it replaces the LineService::canGenerateTrials()
check with a plain create_line permission, which would show trial actions to
resellers that may not generate trials.
- The Redis mGet guard for ResellerTableRenderer::handleLiveConnections, which
Admin\TableController already carries. The method sits at cc=53 with no
coverage, so the CRAP ratchet rightly refuses the hardening until it is
tested — worth doing as its own change.
Verified: 857 tests, make gates, CRAP gate.
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.
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.
- Header: set $xmUiPrefs['rtl'] = $xmIsRtl (was only forced true for RTL), so
switching back to an LTR language clears the direction instead of leaving the
customizer in RTL.
- edit_profile: remove the "Appearance (Design)" panel and its save_ui_prefs
JavaScript. Its data-ui-pref controls (including a manual RTL toggle) carried
no name= so they never posted with the profile form, and the RTL toggle now
conflicts with the language-driven direction. The profile save is a single
request again.
The admin/reseller shells set <html dir="rtl"> in PHP for an RTL language, but
the Vuexy template customizer (config.js) derives the text direction from the
per-user `rtl` UI pref (xcEff.rtl → defaultTextDir) and reset <html> back to ltr
on load, so the layout never mirrored despite lang="ar".
Force $xmUiPrefs['rtl'] = true when the language is RTL, so the customizer keeps
the direction and mirrors navigation, cards, tables and forms.
A mode-dropping deploy left new parent directories tar had to create owned by
root, so PHP-FPM (running as xc_vm) could not traverse into them and the include
500'd (Permission denied). chown the copied files AND their parent dirs to
xc_vm:xc_vm, and extract with --no-same-owner.
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.
nginx maps /CODE/post.php straight to Public/Views/admin/post.php (its
`location ~ \.php$` uses SCRIPT_FILENAME=$request_filename), bypassing the front
controller in index.php that registers the Composer autoloader. Since the PSR-4
migration post.php no longer loaded a bootstrap itself, so every admin form POST
(edit_profile, settings, …) died with a fatal "Class XcVm\Core\Auth\SessionManager
not found" at the first class reference — an empty 500 the app's own Logger never
saw, because its error/shutdown handlers are only registered during the bootstrap
that never ran.
- Guard the standalone case: when the autoloader has not run yet, require
bootstrap.php before the first class use (no-op under the front controller).
- Detect the POST entry directly (the get_included_files() count is unreliable
once the bootstrap is loaded) so a standalone request processes the action
instead of emitting the footer JS helpers.
Port of Rosmi720/XC_VM@16e00cb. Give the download-modal labels their final
wording (custom_format_link) and add the select_an_option string.
Adapted to this tree: ar.ini is absent here, so only en.ini was updated; the
custom_format_link/download keys already existed from the modal feature port.
Port of Rosmi720/XC_VM@0d39e9bd. A mode-dropping deploy could leave core
scripts non-executable and a php-fpm pool config missing, and an undefined HOST
constant broke the unique-header cookie domain:
- StartupCommand restores the executable bit on service/update/redis-server/
daemons.sh and regenerates any missing php-fpm pool config (1..4.conf) from
the template on boot.
- StreamAuthMiddleware derives the cookie domain through a HOST-guarded helper,
so an undefined/empty host or a bare IP yields no domain instead of throwing
or emitting a broken "." domain.
Adapted to this tree: the added boot blocks were split into covered helpers
(ensureExecutableScripts / ensurePhpFpmPoolConfigs / uniqueHeaderDomain) with
unit tests to stay within the complexity ceiling, the sudo chown now runs only
as root, and comments were translated to English.
Port of Rosmi720/XC_VM@fa1e6691. The Quick Stream Links / Xtream API copy
buttons in the Download Playlist modal used the raw Clipboard API, which is
blocked in some in-modal / insecure-context cases and silently failed.
- copyToClipboard() tries navigator.clipboard.writeText and falls back to a
textarea + execCommand appended inside the modal (so focus stays valid),
wrapped in a Promise.
- copyTextWithFeedback() now routes success/empty/failure through the existing
xcToast() global (Copied! / Nothing to copy), with per-button state reset and
a prompt() last resort.
- Read-only fields select their text on focus/click for manual copy.
Views only; xcToast is already provided by the admin/reseller footer.
Port of Rosmi720/XC_VM@064c6c92. Rework the Download Playlist modal on the admin
and reseller line pages to expose ready-to-use links and player credentials:
- Quick Stream Links: M3U Plus, M3U Simple and XMLTV EPG, each with copy and
open buttons.
- Xtream Codes API Details card: server host, port, username and password
(with show/hide), plus a "Copy All Details" action.
- Format selector gains MPEGTS and RTMP outputs; a copyTextWithFeedback() JS
helper drives the per-field copy buttons.
- New en.ini keys for the above.
Adapted to this tree: ar.ini is absent here, so only en.ini was updated, and
the two placeholder key values (custom_format_link/download) were given real
English labels.
Port of Rosmi720/XC_VM@b44abddf. The rogue-connection sweep assumed
ConnectionTracker::getConnections() always returned a MySQL map grouped by
user id and iterated it with a flat double-loop. In Redis mode it returns a
[keys, data] pair, so the inner loop hit string keys and threw a TypeError,
aborting the cron.
Parse the result defensively via a small recursive helper (collectUuidRows)
that flattens any of the shapes — the Redis [keys, data] pair, the MySQL
user-id map and a single connection row — to the rows carrying a uuid, then
filter to the current stream. Both helpers are pure and unit-tested.
Adapted to this tree: the ar.ini/en.ini label tweaks target keys not present
here, so only the cron fix was ported; the added guards were split into
covered helpers to keep loadCron within the complexity ceiling.
Port of Rosmi720/XC_VM@4e91fb68. Encrypted playlist entries carried the output
extension as a URL fragment (play/<token>#.<ext>), which the client strips and
nginx never sees, so every encrypted stream fell back to .ts — VOD/HLS were
effectively broken. Encode the extension in the path instead:
- PlaylistGenerator emits play/<token>.<ext> for live and VOD (Cloudflare TS
keeps the bare token). The live/VOD suffix is a small pure helper
(encryptedPlaySuffix, unit-tested) so generate() stays within its ceiling.
- nginx rewrites play/<token>.<ext> to the auth handler, keeping the legacy
play/<token>/<ext> and bare-token rules for already-saved playlists.
- stream/auth.php splits a <token>.<ext> token and fills the extension when
nginx did not.
- settings label uses the new encrypt_playlists key (en.ini).
Adapted to this tree: ar.ini is absent here, so only en.ini was updated.
Port of Rosmi720/XC_VM@2140b00f. A missing/corrupt MaxMind database or an
undefined HOST constant could fatal the stream request (white screen on live):
- GeoIPService::getIPInfo()/getISP() no longer construct the MaxMind reader
unguarded. A missing constant/binary or any reader error now yields false
instead of a fatal, via a shared readMmdb() helper (unit-tested).
- StreamRedirector::getStreamingURL() reads HOST through a defined() guard so
an undefined constant does not throw while resolving the broadcast domain.
Adapted to this tree: the added guards/try-catch were folded into readMmdb so
getIPInfo/getISP stay within the complexity ceiling.
Port of Rosmi720/XC_VM@8f41d206. Harden the streaming user-info path against
null/malformed data that caused a fatal TypeError on player login:
- ImageUtils::validateURL() accepts null/empty (nullable icon columns) → ''.
- UserRepository::decodeUserFields() tolerates already-decoded arrays, null
columns and malformed JSON (via a shared decodeJsonField helper).
- aggregateBouquetIds() skips non-array bouquet groups (mergeBouquetGroup
helper); getStreamingUserInfo() resolves the bouquet map from the shared
cache / DB when the caller passes none (resolveBouquets helper).
- PlayerApiController::getOutputFormats() guards a non-iterable input and
falls back to the full format set.
Adapted to this tree: kept the existing null-safe category_map / active_cons
handling, and split the added guards into small covered helpers to stay within
the complexity ceiling (unit test for resolveBouquets).
Add category-template selection to the line, mag and enigma edit forms and to
the reseller active-code (voucher) form, plus a reseller navbar entry. Extract
the shared custom_data resolver into CategoryTemplateService::applyCustomData()
and route the Line/Mag/Enigma saves through it, removing the triplicated inline
block; covered by a new unit test.
Review fixes folded in:
- getRecentBatchNames filtered a non-existent `user_id` column and used
DISTINCT + ORDER BY id (rejected by MySQL ONLY_FULL_GROUP_BY). Restore the
correct created_by + GROUP BY MAX(created_at) query (kept the empty-batch
guard).
- routes/admin.php: import ActiveCodeDetailsController (fatal short-name use
without it) for the active_code_details route.
- Revert the accidental re-inline of the ancestorOwnerIds() helper in
getTemplatesForUser and the getResellersWithCodes all-users fallback.
Also: settings save only zeroes checkbox flags on the full settings form
(submit_settings), and reseller bootstrap tolerates a missing language.
Ported from PR #198 (Rosmi720/XC_VM): shared category templates were only
visible to the sharer's direct sub-resellers. Widen visibility to the whole
hierarchy so a template shared by any ancestor reseller reaches every
descendant.
- getTemplatesForUser: walk the ownership chain upward (new cycle-guarded
ancestorOwnerIds helper) and match is_shared templates owned by any
ancestor, not just the direct parent.
- canAccessTemplate: accept a shared template when the caller sits anywhere
in the owner's sub-tree; this subsumes the old direct-parent check.
- Reseller list: add a "Shared with Me" scope tab + count, driven by the
existing scope_type='shared' classification.
- Add CategoryTemplateVisibilityTest covering ancestor visibility and the
access guard (also keeps both methods under the CRAP ratchet).
Deliberately excluded from PR #198: the admin category_template(s) view
rewrites (a competing older implementation that reintroduces the xcToast
argument-order bug and drops the ?create=1 dialog deep-link already fixed
locally), and its language/RTL/active-code/dashboard changes (out of scope).
Adds docs/en/info/licensing-and-activation.md (dual-licensing, AGPL §7b
attribution check, what activation gates, how to activate, what the panel
sends to the licence server and what it never sends, provenance), wires it
into the mkdocs nav (+ ru label), and extends the README "License
Enforcement" section with the activation + data-transparency disclosure.
Makes the activation / attribution / phone-home mechanism formally disclosed
(the legal basis for enforcement on top of AGPL). English source only; ru is
generated.
Adds Licence / Attribution (AGPL §7b) / HWID / Activation-key rows to the
Settings → Info versions table, read from the compiled xcvm_core verdict
(license_valid + verify_branding + install_id). Colour-coded badges:
Licensed/Intact green, Unlicensed/Removed red. Fail-open to N/A via a
method_exists guard (dev/CI or an older .so without the licence methods), so
it never fatals. licenseInfo() helper keeps SettingsController::index()'s
complexity unchanged (CRAP-neutral).
Large deployments always run load-balancer nodes, so LB is the natural
licence chokepoint: an unlicensed white-label panel stays fully usable
locally but cannot add LB servers. server:install refuses type=2 when the
panel is not licensed (community install with attribution, or a valid
activation key), marking the server failed with a clear message.
This is a first-line stopgap (removable PHP); the tamper-proof gate is
XC_VM::db_grant() in the compiled core, which refuses the LB node its DB
access unless licensed. LicenseGate gains licensed() (the general licence
check; fanoutAllowed() now delegates to it).
The added branch is offset by collapsing the cosmetic root/non-root auth
log line into one branchless line (now prints the actual username), so
execute()'s complexity is unchanged.
Shows an activation nudge at the top of the admin dashboard while
MAIN_HOME/config/activation_key is absent — the same file xcvm_core reads
for license_valid(). The banner surfaces the panel HWID (\XC_VM::install_id,
guarded), links to the self-service activation page, and provides a paste
field that submits the key over ajax.
- MiscAjaxController::saveActivationKey (action=save_activation_key): XHR +
adv/settings gated, validates the XCVM1.<b64url>.<b64url> format, writes
the key to config/activation_key (0600).
- Strings live in en.ini/ru.ini (no hard-coded fallbacks in the view).
UI hint only: it never blocks the dashboard; real enforcement stays in the
extension and at the fanout choke points via LicenseGate.
The compiled xcvm_core verify_branding() now reads the canonical attribution
source itself and takes no argument (ArgumentCountError if one is passed).
Update the AttributionGuard seam to call it with no argument; the rendered
$rHtml is still used only by the in-PHP fallback scan when the extension is
absent. Matches XC_VM_CoreExtention commit 9919ef0.
The multi-line grouping parens in the XC_VM_BUILD_ID define (added in
c289352d) tripped Generic.WhiteSpace.ArbitraryParenthesesSpacing, breaking
the green `make cs` baseline. Reflow as a multi-line define() call with the
`?:` grouping paren kept tight. Behaviour unchanged (still 'dev' in a source
checkout). `make cs` green again.
`make main`/`make lb` now stamp a unique per-build id — version + short git
SHA + UTC timestamp + 8 random bytes — into RELEASE_ID at the staged deploy
root (new stamp_release_id target, run after the copy step; the file is
generated per build, never git-tracked). ConstantsInitializer exposes it at
file scope as XC_VM_BUILD_ID by reading RELEASE_ID relative to __DIR__; a
source/dev checkout has no file and reports 'dev'.
This gives every build a traceable fingerprint so a leaked or rebranded copy
can be tied back to its origin (and, once activation ships, reported in the
phone-home alongside install_id). Kept at file scope to avoid adding a branch
to ConstantsInitializer::init() (CRAP ratchet). README discloses it.
Tests: XC_VM_BUILD_ID defined and defaults to 'dev' in a source checkout.
Full suite (817) + gates + CRAP + phpcs green; Makefile targets validated.
Adds LicenseGate::fanoutUsable() — the single "should this request use the
fanout daemon?" check (control socket present AND licence permits it). The two
fanout choke points now consult it instead of bare file_exists(FANOUT_CTL_SOCK):
StreamProcess::supervisionEnabled() and the three delivery tests in
Public/stream/live.php. When it returns false, live streams fall back to the
legacy delivery path automatically (soft enforcement) — nothing is hard-blocked.
The licence verdict itself (HWID-bound key tied to install_id, hidden
method-selector bytes, weekly re-validation, 7-day offline grace) lives in the
compiled xcvm_core extension; PHP only reads it via \XC_VM::license_valid()
when present. Fail-open where the extension or method is absent (dev/CI and
installs without xcvm_core), so this is behaviour-preserving until the C side
ships the verdict — and never bricks a working panel.
Helper kept in the new LicenseGate (not FanoutClient) to avoid touching that
debt-heavy file. supervisionEnabled() simplifies (cc 4->2). README disclosure
for activation will land when the verdict goes live.
Tests: fail-open without extension, unusable without control socket. Full
suite (816) + gates + CRAP + phpcs green.
Route the admin and reseller login-page attribution through the single
AdminHelpers::getAttribution() source (portal's "Smart Activation Engine"
line is unrelated and left as-is), so the notice can no longer be stripped
from a login page independently of the guarded source.
Add the forward-compatible enforcement seam: AttributionGuard now delegates
to \XC_VM::verify_branding() when the compiled xcvm_core extension exposes
it (guarded by class_exists/method_exists), falling back to the in-PHP
marker scan in dev/CI and installs without the extension. This moves the
real, un-strippable check into the ioncube-protected extension once the C
side ships it; the PHP path remains only a deterrent.
Gates: full suite (814) + procedural-use + CRAP + phpcs green.
Adds a disclosed, reversible attribution-integrity check (AGPL-3.0 §7(b)).
The "Vateron Media · AGPL-3.0" credit now has a single source
(AdminHelpers::getAttribution(), reused by getFooter). AttributionGuard
verifies its markers; AttributionVerificationStage runs the check on the
Admin (UI) boot profile only — admin/reseller/player panels — and calls
generateError('ATTRIBUTION_REMOVED') when the notice is gone.
Scope is deliberately UI-only: streaming is NOT gated, so removing a UI
footer cannot cut off a reseller's end-viewers (that is the licence
layer's concern). The lock is non-destructive and auto-reverses — the CLI
stays usable and restoring the notice unlocks the panel on the next
request. Enforcement is documented in README (License Enforcement).
This is a readable-PHP deterrent (a determined actor can remove the check
with the notice); tamper-resistant enforcement belongs in the compiled
xcvm_core extension (planned).
Tests: attribution markers, hasMarkers tamper detection, CLI self-skip,
UI-only wiring, error-code registration. Full suite + gates + CRAP green.
Replace the `<severity>0</severity>` mute block with single-line toggles so
disabled sniffs can be re-enabled one at a time, per the technical-debt
ratchet. Two mechanisms, because phpcs `<exclude>` cannot be overridden by a
later ref:
- sniffs with their own `<rule ref>` are commented out in place (uncomment
to enable): CamelCapsFunctionName, MultiLineAssignment,
UnnecessaryStringConcat, SAPIUsage, AbstractClassNamePrefix,
InterfaceNameSuffix, TraitNameSuffix, DeprecatedFunctions, ClassFileName,
GlobalKeyword;
- sniffs pulled in by the PSR12 parent are `<exclude>`d inside it (delete the
exclude to enable): CamelCapsMethodName, FileHeader.IncorrectOrder,
MethodDeclaration.Underscore, SwitchDeclaration.TerminatingComment,
PSR1.Classes.ClassDeclaration, PropertyDeclaration.Underscore,
ValidClassName;
- CyclomaticComplexity/NestingLevel MaxExceeded are message-level excludes on
their own refs (TooHigh warning still reports).
ControlStructureSpacing and PEAR FunctionCallSignature (tab-incompatible) and
ParameterTypeHint (null-crash fixer footgun) are marked DO NOT ENABLE. Added a
ratchet legend documenting every toggle, its violation count, and a suggested
enable order. `make cs` stays green (0 errors) and `make cs-fix` converges;
both toggle directions verified.