database.php set $ACCESS_PWD to the boolean result of defined('DB_ACCESS_ENABLED'),
so the configured DB_ACCESS_PWD was never used as the phpMiniAdmin password. Source
it from the DB_ACCESS_PWD constant instead.
AppConfig ships safe defaults: DB_ACCESS_ENABLED=false and an empty DB_ACCESS_PWD
(a repo-committed password is public and protects nothing) — admins opt in and set
a strong password per install. Access stays behind the admin session + is_admin;
an empty password admits only local IPs.
nginx serves the existing post.php directly (SCRIPT_FILENAME=$request_filename),
bypassing the front controller, so module classes were never loaded and module
services (e.g. WatchService) resolved as not-found. Boot the modules in
standalone mode from post.php so module-provided services are available.
ModulesController handles the update action (updateModuleFromSource) and a delete
action. The modules view gates each button by state: Install only when not
installed, Update only when a newer available_version exists, Uninstall when
installed, Delete always.
ModuleManager:
- modulePathFor() resolves {name}_{hash5} (glob) or a legacy bare {name}.
- ensureHashId() generates + persists a hash_id for uploads that lack one, so a
module is never placed in a hash-less directory; moduleDirName() is now strict.
- placeModuleFiles() always lands modules in {name}_{hash5}.
- migrateLegacyModuleDirs() renames bare {name} dirs to {name}_{hash5} (dropping
stale bare duplicates); invoked from syncBundledModules() on every status pass.
- listModules() keys by canonical manifest name, not directory basename.
- Fix "unpacks but does not install": loadModuleInstance() and readModuleManifest()
now resolve via modulePathFor() instead of building a bare modulesPath/{name}
path, which failed for a freshly-uploaded hash-suffixed module.
- update-source plumbing: normalizeUpdateBlock consumption, updateModuleFromSource,
standard-set provisioning, findModuleByHashId.
ModuleLoader:
- loadAll() no longer aborts all module loading when one module fails: it logs,
marks the module failed and skips it (and its dependents), keeping the panel
and CLI up. Cyclic / unknown-in-graph errors still throw.
- normalizeUpdateBlock() + hash_id/update in the normalized manifest.
Rename watch->watch_2541a, plex->plex_20cd9, tmdb->tmdb_f4e6e,
ministra->ministra_85a7d, where the suffix is the first 5 chars of each
module's hash_id. The logical name (module.json "name", never containing "_")
stays canonical, so config keys, namespaces and dependencies are unchanged —
only the on-disk directory carries the hash. This lets same-named modules
coexist on disk. deleted_files.txt captures the old paths at release time
(make generate_deleted_files, --no-renames).
config/bundled_modules.php is the standard-set registry keyed by hash_id (stable
across renames). ModuleDeleteCommand (module:delete) fully removes a module
(uninstall -> drop files -> config). RootSignalsCronJob gains a delete_module
signal case so a delete on MAIN propagates to LB nodes.
ModuleUpdateChecker resolves the latest available version per module by source:
git (via GitHubReleases), url (version.json), platform (best-effort), bundled
(no-op). ModuleUpdatesCronJob (cron:module_updates) records available_version
into config/modules.php; migration 008 registers it to run weekly. Network-free
unit tests cover the routing.
- Print a "GeoIP (MaxMind)" section header to match the Database/Migrations blocks.
- A successfully downloaded GeoLite2 DB is now always saved; a missing or
mismatched checksum (hashes.md5 can time out over SSL) only downgrades the
status to a warning instead of discarding the file. Only a real download
failure is treated as an error.
The absolute-path symlink src/Public/assets/reseller -> /home/xc_vm/public/assets/admin
broke `make main` (cp: cannot stat). Remove it from git and let
XC_Bootstrap::ensureResellerAssetsSymlink() create a relative reseller -> admin
link on admin boot (idempotent, repairs a stale/broken link).
Mirror core's DB layout at the module level: one master database.sql (full
current schema), one database_drop.sql (teardown), and forward-only
migrations/<semver>.sql deltas. ModuleMigrator gains install()/uninstall();
up() reads migrations/*.sql; down() removed.
- watch: database.sql (delete_missing folded into watch_folders),
database_drop.sql, migrations/1.0.1.sql; drop 1.0.0.up/down.sql;
getVersion() -> 1.0.1 (matches module.json).
- Move core migration 004_add_watch_delete_missing.sql into the watch module.
- installModule no longer wraps the DDL migration in a transaction: MySQL/MariaDB
implicitly commit DDL, so the wrapping rollback() only masked the real SQL error.
- syncBundledModules retries a previously-failed install (skips only admin-disabled)
so `console.php status` self-heals.
Cleanup removed files older than time()-10, but a full run takes far longer than 10s, so caches written early in the loop were deleted, leaving most channels without EPG. Stamp $runStart before generation and only remove files with mtime < $runStart.
installedDependentsOf() and syncBundledModules() called $this->list(), which does not exist — the method is listModules(). Fixes PHPStan method.notFound.
The LB build strips MAIN-only domains (Domain/User, Domain/Device, Domain/Auth), so DomainDatabaseWiring::wire() fataled with 'class not found' on load-balancer nodes (EnigmaService, then EPG, ...). Iterate a flat list of domain services and guard each setDb() call with class_exists(), so classes absent from a given build are skipped instead of crashing boot.
The 5s total transfer timeout aborted the /releases list (tens of KB of changelogs) mid-download during LB installs, when the link is saturated by apt/wget. Bump the transfer timeout to 30s and add a 10s CURLOPT_CONNECTTIMEOUT so unreachable hosts still fail fast.
console.php startup runs as root and is the first reader of an LB transport config.enc; the extension rewrites it to at-rest format owned by root, which blocks the xc_vm daemons (signals/watchdog/queue/cache_handler) from reading it. chown config.enc back to xc_vm before they start (idempotent on later boots).
install_id() created config/install_id as root, so PHP-FPM (xc_vm) could not read it and config.enc decryption fell back to a default config (server_id=1, is_lb=0). Create config/ and run install_id() as xc_vm before packing, chown the uploaded config.enc to xc_vm, and reorder runStartup so the chown precedes any PHP run and status runs as xc_vm — root never touches install_id/config.enc.
Make the bundled watch/plex modules own their database schema and lifecycle
instead of being hard-wired into the base schema, and decouple core code from
module-owned tables.
File-based module migrations:
- ModuleMigrator runs Modules/<name>/migrations/<semver>.up.sql / .down.sql,
ordered by semver; installed_version (config/modules.php) is the watermark.
- Wired into ModuleManager: install → up(null→target), update → up(from→target),
uninstall → down(installed). Install is transactional (a failing migration
rolls back and marks the module Failed).
- watch ships 1.0.0.up/down.sql (creates/drops watch_categories, watch_folders,
watch_logs, watch_refresh + seeds the default TMDb genre categories with
INSERT IGNORE). Removed those tables from bin/install/database.sql — the
module is now the single source of that DDL.
Module lifecycle:
- syncBundledModules() auto-installs never-installed bundled modules in
dependency order; called from StatusCommand after MigrationRunner::run so a
fresh install/update provisions their tables. Idempotent.
- uninstallModule() refuses to remove a module that installed dependents still
require.
- plex declares "dependencies": ["watch"], owns no tables, and on uninstall
deletes only its own rows (watch_folders type='plex', categories 3/4).
Decouple core from watch tables via events (core would otherwise fatal once the
tables are droppable):
- New StreamsDeletedEvent / BouquetDeletedEvent, dispatched from
StreamRepository::deleteStream/deleteStreams, ToolsCommand::deleteStreams and
BouquetService::deleteById in place of direct DELETE/UPDATE on watch tables.
- WatchModule subscribes via #[ListensTo] and delegates to WatchService, which
now holds the watch_refresh/watch_logs/watch_folders cleanup. Fixes a latent
bug where bouquet deletion wiped fb_bouquets (column was read but not SELECTed).
- Moved TMDbService::updateCategories() (writes watch_categories) into
WatchService; it now builds the client via the PSR-4
TmdbApiService::createClient() instead of a manual require of the legacy lib
(createClient is now self-contained).
Switch GeoIP/ISP lookups from the native maxminddb C extension to the
pure-PHP maxmind-db/reader, pulled in via geoip2/geoip2. This removes a
compiled-extension runtime dependency — the panel now works on any stock
PHP 8.1+ without building or installing maxminddb.so.
Dependencies:
- Add geoip2/geoip2 ~3.0 (brings maxmind-db/reader, maxmind/web-service-common)
- Rebuild committed vendor/ with `composer install --no-dev` (production-only)
Code fixes:
- Resolve MaxMind\Db\Reader against the global namespace (leading backslash)
in namespaced classes — `new MaxMind\Db\Reader(...)` previously resolved to
a non-existent XcVm\...\MaxMind\Db\Reader and would fatal once the extension
was gone. Fixed in GeoIPService, ResellerApiDispatcher, admin/api.php view.
- Update GeoIP util docblock to reflect the Composer-based source.
Installer / runtime cleanup:
- bin/php/lib/php.ini: comment out `extension=maxminddb.so`
- install (main installer) and LbInstallFlow: drop libmaxminddb0/-dev,
libmaxminddb/-devel and the now-unused mmdb-bin from all distro package
lists (Debian/Ubuntu/RHEL)
Behavior, the raw `->get()` array format and the on-disk GeoIP file cache
are unchanged; legacy libGeoIP packages are left untouched.
Replace the fragile per-class setDb()/db() pattern (which threw when a
bootstrap path forgot to wire $db) with a shared trait that lazily resolves
from DatabaseFactory. Fixes the streaming UserRepository fatal and the same
latent issue in module crons. 42 classes converted.
Replace the bundled src/Core/Util/MobileDetect.php (Mobile_Detect v2.8.45, copied
into XcVm\Core\Util) with the maintained Composer package, like M3uParser/PhpM3u8.
- composer require mobiledetect/mobiledetectlib (^4.9; 4.11 needs PHP 8.2, deploy
is 8.1.33 → Composer selected 4.9.0). Adds psr/simple-cache as a prod dependency.
- bootstrap.php: new \XcVm\Core\Util\Mobile_Detect() -> new \Detection\MobileDetect();
drop the manual require_once (Composer autoloads it). isMobile() unchanged.
- Remove the bundled MobileDetect.php and its phpstan.dist.neon analyse-exclude.
- .gitignore: whitelist the new prod packages under the prod-only vendor —
!src/vendor/mobiledetect/ and, since psr/ is mixed, un-ignore psr/ then
re-ignore psr/* and whitelist only psr/simple-cache (psr/container,
event-dispatcher, log stay dev-ignored).
- Commit the prod-only vendor + updated composer.lock.
LoadBalancer nginx had no ^/admin/ location, so when a server higher in
the tree pulled a stream whose source is an LB, the LB returned 404 for
/admin/{live,timeshift,thumb,vod} before the request ever reached PHP.
The MAIN→LB direction worked (MAIN has the route); LB→MAIN was silently
broken — stream shown down, nothing in the panel logs.
Add a reduced admin location exposing ONLY the loopback handlers
(live|timeshift|thumb|vod) — not index|api|proxy_api — so the admin
panel itself is never served from a LoadBalancer. Mirrors the MAIN
admin gateway block.
Also fix a PSR-4 rename miss: the directory rename public/ → Public/
(1a296d09) updated src/bin/nginx/conf/nginx.conf but left
lb_configs/nginx.conf pointing at /home/xc_vm/public/ everywhere
(root, stream, progress, api). On a case-sensitive fresh LB install the
on-disk path is Public/, so every SCRIPT_FILENAME 404'd — all LB
streaming broke, not just loopback. Repoint the 8 filesystem paths to
Public/; SCRIPT_NAME stays /public/ (logical CGI path, matches MAIN).
Note: bin/nginx is update-excluded, so existing LBs keep their old
config and must be patched manually or reinstalled; only fresh installs
pick up the corrected lb_configs/nginx.conf.
- Exclude bundled third-party code from analysis (Modules/tmdb/lib/*,
Core/Util/MobileDetect.php) — like the vendored Composer packages, these
are not our code (php-tmdb client, Mobile-Detect). Removes 67 baselined
errors incl. the only class.notFound and phpDoc.parseError (both vendored).
- return.void (10): drop the discarded return value (return true/false/null →
return;) in void methods — NetworkUtils, LineService, StreamProcess, TMDbService.
- deadCode.unreachable (2): remove the dead `return;` after AdminHelpers::goHome()
(a never-returning call) in Plex/WatchController.
- Regenerate phpstan-baseline.neon: 440 -> 361 errors (291 -> 241 blocks).
Verified: make phpstan green, PHPUnit 298/298.
The PSR-4 migration's leading-backslash qualification leaked into string
require paths, turning 'Modules/tmdb/lib/Release.php' into
'Modules/tmdb/lib/\Release.php' → "Failed to open stream: No such file or
directory" at runtime (TmdbCronJob, TmdbCron, watch/WatchItemCommand). Remove
the stray backslash in all three. PHPStan can't catch this (constant + string
literal concatenation in require), so it surfaced only at runtime.
The per-access-code nginx configs are generated from
bin/nginx/conf/codes/template{,_ministra} by AuthRepository::updateCodes().
Both templates still hard-coded the pre-migration lowercase path
`fastcgi_param SCRIPT_FILENAME /home/xc_vm/public/index.php` and
`alias /home/xc_vm/public/assets/...`, while the directory was renamed to
Public/ during the PSR-4 migration. On a case-sensitive filesystem PHP-FPM
gets a non-existent SCRIPT_FILENAME and answers "File not found." for every
access-code URL.
Point the filesystem paths at /home/xc_vm/Public/ (SCRIPT_NAME, a logical URI,
is left as /public/ to match nginx.conf). The migration's path sweep only
touched PHP files under src/ and missed these nginx templates.
PHP string class names are always fully-qualified (they ignore the current
namespace and use-imports), so several short-name string references silently
broke when their classes were namespaced — failing only at runtime.
- StreamUtils::sortArguments(): the uasort() callback array('StreamUtils',
'customOrder') referenced the now-namespaced class by its old global short
name -> "class StreamUtils not found". Use array(self::class, 'customOrder').
- Router::checkPermission(): `class_exists('Authorization')` was always false
(Authorization is now XcVm\Core\Auth\Authorization), so the ['type','key']
permission branch never called check() and fell through to `return true` —
granting access unconditionally. Call Authorization::check() directly.
- TmdbController::hasMediaPermission(): `class_exists('Authorization')` always
false -> method always returned false (media permissions always denied). Drop
the dead guard; the call already imports the FQCN.
- AdminLogoutController: `class_exists('SessionManager')` always false -> the
admin session was never cleared on logout. Call SessionManager::clearContext()
directly.
- BruteforceGuard::getDB(): `class_exists('DatabaseFactory', false)` always
false -> qualify to \XcVm\Infrastructure\Database\DatabaseFactory::class.
Verified: php -l clean; StreamUtils callback is callable; make gates pass;
PHPUnit green (incl. StreamUtilsTest).
Switch from "commit vendor with dev deps + strip at release" to the standard
application model: the committed src/vendor/ is PRODUCTION-ONLY, and dev tooling
(PHPStan, PHP-CS-Fixer + ~37 transitive deps) is installed on demand with
"composer install".
- Regenerate the committed src/vendor/ via "composer install --no-dev"
(34 MB -> ~0.5 MB; only the Composer autoloader + gemorroj/m3u-parser +
chrisyue/php-m3u8 remain). This also stops PHPStan\PharAutoloader registering
in production.
- Commit src/composer.lock (un-ignored) — this is an application, so the lock is
committed to make "composer install" reproducible across dev/CI.
- Revert the release-time strip step (Makefile hooks + tools/build/
strip-dev-vendor.sh) — no longer needed; the archive ships the prod vendor as-is.
- CI: the phpstan and code-style jobs now run "composer install --working-dir=src"
(with tools: composer) to obtain the dev tools before running.
- New gate tools/ci/check-vendor-prod-only.sh (+ make check-vendor-prod-only,
wired into "make gates"): asserts no require-dev package from composer.lock is
committed under src/vendor/ — guards against accidentally committing a
dev-bloated vendor. Inspects git-tracked files, so it is correct even in a CI
job that already ran "composer install".
- Fix verify-lb-archive.sh: LB legitimately ships most of Cli/Commands and
Cli/CronJobs (edge commands + certbot/cache/cleanup crons), so flag only the
genuinely privileged dirs + the specific install/root files, not the whole dirs.
- .gitignore / composer.json notes updated.
Verified before pruning: PHPStan no errors, PHPUnit 303, cs + gates green. After
pruning: PHPUnit 303 (prod-only vendor), all three gates green. Local dev tools
restored afterwards with "composer install" (not committed).
The XC_Autoloader fallback was already retired (no-op stub); this deletes it for
good. Resolution is now 100% Composer PSR-4 (+ ModuleLoader for modules), no
legacy scanner, no class-map cache.
- Move `define('MAIN_HOME', ...)` into bootstrap.php (autoload.php used to define
it); bootstrap.php now requires only vendor/autoload.php.
- Drop `\XC_Autoloader::clearCache()/warmCache()` from StartupCommand.
- tests/bootstrap.php: locate-guard + require switched to vendor/autoload.php.
- Entry points that required autoload.php directly — Public/index.php,
Public/admin/index.php, Public/stream/index.php, Public/progress/index.php,
ministra/portal.php and Admin/Reseller TableController — switched to
vendor/autoload.php (defining MAIN_HOME where they did not already). These were
not in the plan's checklist; found via grep during execution.
- phpstan.dist.neon: drop src/autoload.php from scanFiles.
- Makefile: drop autoload.php from LB_ROOT_FILES.
- deleted_files.txt: add autoload.php (client cleanup on update).
- AutoloadOrderTest: now asserts the XC_Autoloader class and file are gone.
- git rm src/autoload.php.
- PSR4_MIGRATION_PLAN.md: mark final-phase step 2 done.
Verified: grep XC_Autoloader:: = 0; php -l clean; PHPStan no errors; PHPUnit
303/303; make gates pass; bootstrap smoke — MAIN_HOME + XC_Bootstrap present,
XC_Autoloader gone, only the Composer autoloader registered.
enigma, episode, episodes, process_monitor and stream_view referenced migrated
classes (UserRepository, BouquetService, StreamRepository, RequestManager,
SettingsManager, ...) by short name with either no `use` or a `use` placed
*below* the first usage. PHP imports outside the top scope are positional, so the
short name resolved to a now-nonexistent global class — a runtime fatal on those
admin pages (php -l passes; not covered by PHPStan/PHPUnit). The migration's
automated `use` insertion missed them due to the interleaved HTML / short-tag
(<? , <?=) structure, and the later php-cs-fixer pass stripped some as 'unused'
because it could not see usage inside short-tag blocks.
- Consolidate every needed `use XcVm\...;` into a single top-of-file PHP block.
- Exclude Public/Views and Modules/*/views from php-cs-fixer (no_unused_imports
is unreliable on short-tag templates); their import correctness is enforced by
the new check_procedural_use gate instead.
Verified: php -l (short_open_tag=1) clean; PHPStan no errors; PHPUnit green.
Apply 'make cs-fix' — 493 files. Mechanical, import-block only:
- sort use statements alphabetically (class/function/const grouped);
- drop imports left unused by the PSR-4 migration (e.g. classes referenced by
leading-backslash FQCN whose redundant 'use' the automated insertion had added);
- one blank line after namespace and after the import block; collapse stray
blank lines around use.
No logic changes. Verified: php -l clean; PHPStan no errors; PHPUnit 295/295; and a
temporary PHPStan pass over src/Public/Controllers confirms no still-referenced
import was removed (0 unresolved classes). 'use' after inline HTML in view
templates is valid and aliases correctly (verified) — those imports are sorted too.
Add friendsofphp/php-cs-fixer as a committed Composer dev dependency (src/vendor/,
same model as PHPStan — no composer install on deploy).
- .php-cs-fixer.dist.php: deliberately NARROW ruleset — no_unused_imports,
ordered_imports, no_leading_import_slash, single_line_after_imports,
blank_line_after_namespace, no_extra_blank_lines[use]. NO @PSR12 / indentation
rules: the codebase is tab-indented legacy and a full reformat would be
unreviewable. Indent forced to tabs, LF endings. Excludes vendor, the bundled
Modules/tmdb/lib, tmp/, backups/.
- Makefile: 'make cs' (dry-run, fails on diff — CI) and 'make cs-fix' (apply).
- CI: new 'Code Style (PHP-CS-Fixer)' job running 'make cs' on PHP 8.3.
- .gitignore: ignore .php-cs-fixer.cache.
The per-file PSR-4 migration is complete: every class resolves via the Composer
autoloader (src/vendor/, PSR-4 XcVm\* + M3uParser/Chrisyue) or an explicit require
(the bundled tmdb/lib + M3u libs, MinistraBootstrap, procedural entry points). The
ioncube XC_VM class stays guarded.
- XC_Autoloader::init() is now a no-op: it no longer registers the SPL directory
scanner. The class is kept one release as a no-op stub (its clearCache()/
warmCache() remain so StartupCommand keeps working); it will be deleted in a
follow-up (then dropped from bootstrap require / PHPStan scanFiles /
LB_ROOT_FILES / deleted_files.txt).
- bootstrap.php still requires autoload.php (it defines MAIN_HOME and the stub).
Verified: only the Composer autoloader is registered (XC_Autoloader gone from the
SPL stack); every layer + vendored lib resolves via Composer; the 4 modules load
via the ModuleLoader PSR-4 resolver; PHPStan no errors; PHPUnit 295/295 — and ~20x
faster (0.26s vs 5s) now that class misses no longer trigger a full directory rescan.