Commit Graph
1761 Commits
Author SHA1 Message Date
Divarion_D bb65ea6c8e feat(reseller): flesh out the dashboard media panels
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
2026-09-22 22:36:26 +03:00
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 1e2a0d13c1 test(e2e): give the category poll longer than one heavy cache cycle
`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).
2026-09-22 21:50:52 +03:00
Divarion_D 68ffae1384 fix(reseller): put Kill Connections on the badge and drop the Actions column
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.
2026-09-22 21:12:17 +03:00
Divarion_D 7cdf2e37dd fix(reseller): drop the Actions column when there is no action to offer
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.
2026-09-22 21:05:21 +03:00
Divarion_D 415af7d91e refactor(admin): move the modules page onto ./table and ./api
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.
2026-09-22 20:59:47 +03:00
Divarion_D 316e1e6aa9 build(tools): let sync-dev.sh deploy into a Docker container
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.
2026-09-22 20:59:35 +03:00
Divarion_D 3169387d55 fix(cli): read the xcvm_core version from PHP and fetch the current build
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.
2026-09-22 20:59:34 +03:00
Divarion_D 34d6f65de3 fix(admin): gate the activation banner on the key being accepted
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.
2026-09-22 20:59:20 +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 b9de28f624 build: widen the Rector set, fix its cache trap, and quiet the fixerless phpcs sniffs
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.
2026-09-22 18:20:45 +03:00
Divarion_D be7e0c13e2 style: declare visibility on the nine remaining class constants
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.
2026-09-22 18:20:27 +03:00
Divarion_D b8c84939d1 refactor: remove the last goto statements
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.
2026-09-22 18:20:14 +03:00
Divarion_D 4236f1ea96 refactor(admin): build the country pickers from GeoReference instead of inline copies
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.
2026-09-22 18:19:58 +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
Divarion_D 10958863f9 chore(phpdoc): drop the stale nullable from resolveCategoryIds' @param
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.
2026-09-22 17:11:30 +03:00
AbdoAhmedElbanaaandDivarion_D 9164e3ee81 feat(reseller): group the sidebar into sections and fix active-item highlighting
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.
2026-09-22 17:09:20 +03:00
AbdoAhmedElbanaaandDivarion_D 78d7b23212 feat(reseller): trial deep-links, voucher balance card, Host header guard
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.
2026-09-22 17:00:30 +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('&nbsp; ', ...) padding hack that rendered literal
  '&nbsp;' 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
Divarion_D 6dcad96aa0 fix(admin): bind RTL strictly to language and drop the profile Appearance panel
- 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.
2026-09-21 21:53:08 +03:00
AbdoAhmedElbanaaandDivarion_D 0c9c7f7cdf fix(i18n): drive the template RTL direction from the active language
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.
2026-09-21 21:48:13 +03:00
Divarion_D bb4e1eac74 fix(tools): chown created parent directories in sync-dev.sh
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.
2026-09-21 21:32:28 +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
Divarion_D 5aceef2799 fix(admin): bootstrap the autoloader when post.php is served standalone
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.
2026-09-21 21:32:06 +03:00
AbdoAhmedElbanaaandDivarion_D 73b8bbbdd3 chore(i18n): update translation strings for download modal and copy actions
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.
2026-09-21 19:48:28 +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 8ae1342bbc fix(ui): resolve clipboard copy inside modal and enhance visual toast feedback
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.
2026-09-21 19:33:50 +03:00
AbdoAhmedElbanaaandDivarion_D bdaccc9805 feat(ui): enhance download playlist modal with quick stream links and Xtream API credentials
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.
2026-09-21 19:32:15 +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
DanilandGitHub 29b4d7218f Merge pull request #201 from Vateron-Media/dependabot/composer/src/composer-d3807e316f
deps: bump the composer group in /src with 2 updates
2026-09-20 20:19:31 +03:00
dependabot[bot]andGitHub 34c548248c deps: bump the composer group in /src with 2 updates
Bumps the composer group in /src with 2 updates: [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) and [rector/rector](https://github.com/rectorphp/rector).


Updates `phpstan/phpstan` from 2.2.13 to 2.2.14
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

Updates `rector/rector` from 2.6.6 to 2.6.7
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.6.6...2.6.7)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
- dependency-name: rector/rector
  dependency-version: 2.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-20 10:03:08 +00:00
Divarion_D 2bea5f0519 feat(settings): drop the Attribution row from the licence indicator
Keeps Licence / HWID / Activation key; removes the Attribution (AGPL §7b)
row and its now-unused verify_branding() call from licenseInfo().
2026-09-19 22:32:31 +03:00
Divarion_D cdfaab0b3c docs(licensing): disclose the activation model & data sent to the server
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.
2026-09-19 22:16:48 +03:00
Divarion_D a3f004953e feat(settings): licence status indicator on the Info tab
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).
2026-09-19 21:07:36 +03:00
Divarion_D d47c4915a7 feat(license): block load-balancer install on an unlicensed panel
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.
2026-09-18 21:13:37 +03:00
Divarion_D f8a04a1281 feat(dashboard): activation banner + self-service key entry
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.
2026-09-18 20:20:28 +03:00
Divarion_D f585b8f39a refactor(integrity): call no-arg XC_VM::verify_branding()
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.
2026-09-18 16:41:46 +03:00
Divarion_D 0039e889e2 style(cs): fix parenthesis spacing in XC_VM_BUILD_ID define
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.
2026-09-18 16:13:53 +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 328c6db2f5 feat(integrity): single-source login attribution + xcvm_core seam
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.
2026-09-18 15:20:32 +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
Divarion_D ef88d7e325 style(phpcs): convert muted rules to comment/exclude toggles
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.
2026-09-18 13:44:50 +03:00