Files
Adam Daley 0eb833e026 Reorganise FOSSBilling core library (#4210)
* refactor(library): reorganize FOSSBilling core library

Group Update* prefix into FOSSBilling\Update\{Updater,Patcher,Finalization,ReadinessCheck}
Group Pagination into FOSSBilling\Pagination\{Service,Options,ViewPaginator}
Group System (Config,Environment,Version,Requirements) into FOSSBilling\System
Consolidate Security (Crypt,PasswordManager,Fingerprint,Session,CheckResult/Enum)
Group Exception into FOSSBilling\Exception\{BaseException,InformationException}
Fix I18n case (i18n.php -> I18n/I18n.php) and move Translate -> I18n/Translator
Group Logging (Logger, Monolog -> Factory)
Group Remote (CentralAlerts,ExtensionManager), move ErrorPage -> Http, InjectionAwareInterface -> Interfaces
Update all call sites (di.php, load.php, modules, tests) with no BC shims
Fix import aliasing and phpstan docblocks, remove unused imports
Tests: 2856 passed, phpstan OK

* refactor(validation): migrate Validate into Validation namespace

Split monolithic FOSSBilling\Validate (232 lines, 7 mixed responsibilities)
into focused validators:
- Validation\DomainValidator (isSldValid, isTldValid with DI cache/http)
- Validation\PasswordValidator (isPasswordStrong, passwordsMatch)
- Validation\EmailValidator (isEmailValid)
- Validation\BirthdayValidator (isBirthdayValid)
- Validation\RequiredValidator (checkRequiredParamsForArray)
- Validation\Validator facade retaining same API for $di['validator'] BC-free

Move FOSSBilling\Validate -> FOSSBilling\Validation\Validator,
add FOSSBilling\Validation\DomainValidator via $di['domainValidator'],
keep $di['validator'] as facade. Remove old Validate.php.
Update all call sites and tests (FOSSBilling\Validate -> FOSSBilling\Validation\Validator),
make Validator/DomainValidator non-final for Mockery.
Tests: 2856 passed, phpstan OK

* refactor(tools): split Tools into Utils/Security/Sanitizer/Validation (WIP)

Extract Tools god class (534 lines) into focused helpers:
- Utils\Str (slug, toCamelCase, fromCamelCase)
- Utils\Arr (safeCount, sortByOneKey, getPairsForTableByIds via arr service)
- Utils\Normalizer (normalizeBoolean, normalizePort)
- Utils\Network (isValidHttpInterface, getDefaultInterface, listHttpInterfaces, getExternalIP via network service)
- Utils\Format (humanReadableBytes)
- Security\Credential (generatePassword, session restore tokens)
- Sanitizer\Sanitizer (sanitizeContent, sanitizePlainText, sanitizeMarkdownContent)
- Validation\PhoneValidator + EmailValidator::validateAndSanitizeEmail

Update di.php: replace tools service with arr/network services,
migrate 23 src files to new helpers (FOSSBilling\Utils etc.),
keep Tools.php for remaining tests (to be removed next).
Tests: partial, 11 errors remain for slug mocks (to be fixed).

* refactor(api): introduce Role enum, tighten Api layer (deslopped)

- Add FOSSBilling\Api\Role enum (guest/client/admin) replacing stringly-typed role handling
- Api\Identity now stores Role, exposes getRole():Role + getType():string (BC), typeFromObject():Role
- Api\AbstractApi: make abstract, typed properties (string $ip, ?Module $module, Client|Admin|Guest|null $identity), typed setters/getters, fix BaseException usage
- Api\Proxy: compose Identity VO instead of duplicating type logic, keep lazy dispatcher from container, remove redundant helpers
- Api\Dispatcher: use Role enum and Identity VO, normalize identity, keep direct $di['update_finalization'] lookup, deslop finalization injection and apiClassSuffix helper
- Update tests/Pest IdentityTest to expect Role enum
- Minor: fix Cron/Staff Api call sites for typed identity

Deslop: removed hasModule/hasIdentity/hasService helpers, duplicate forwarding comment, unnecessary setFinalization/constructor injection and apiClassSuffix helper.

* refactor(cache): introduce Driver enum, use typed driver handling

- Add FOSSBilling\Cache\Driver enum (filesystem/redis/memcached)
- CacheFactory: parse config via Driver::tryFrom, match on enum, use ->value for messages, remove unused Tools import
- System\Api\Admin: validate driver via Driver::tryFrom instead of in_array(SUPPORTED_DRIVERS)

* refactor(doctrine+api): address review findings

- Doctrine: remove unused Tools import in DriverManagerFactory
- Doctrine: add Driver/Charset enums (pdo_mysql, utf8/utf8mb4/latin1) and use tryFrom for validation
- Api: make AbstractApi::setModule(object) lenient for test doubles (anonymous class in DispatcherTest)
- Tests: apiEndpoint() now ensures identity via getIdentity() catch and sets Admin/Client/Guest based on Api class

* Type AbstractApi module as Module

* Update Doctrine factory copyright years

* refactor(enums): relocate Client/Order status enums to domain modules

Move FOSSBilling\Enums\ClientStatusEnum -> Box\Mod\Client\Enum\Status
and FOSSBilling\Enums\ClientOrderStatusEnum -> Box\Mod\Order\Enum\Status,
colocating statuses with their owning entities (Client.php:29,
Order.php:24) and removing the generic FOSSBilling\Enums catch-all.
Drops Enum suffix, fixes ClientOrder vs Order naming mismatch, updates
sole consumer Massmailer\Service and deletes src/library/FOSSBilling/Enums/.

* refactor(exception): clean up BaseException and InformationException

- BaseException: type stackTrace(int $length,bool $protected), fix
  camelCase ($Length->$length, $my_entry->$arg, $entry_file/file),
  debug_backtrace typing, copyright 2025->2026
- InformationException: remove redundant passthrough __construct, now
  minimal marker extends BaseException (retains 100+ user-facing
  throw sites and Sentry ignore distinction)

* refactor(http): harden Http factories, fix escaping and copyrights

- CsvResponseFactory: validate table name preg_match /^[a-zA-Z0-9_]+$/ and throw
  InformationException, add import, copyright 2025->2026
- ErrorPage: escape message via htmlspecialchars in renderPage, use
  escapedMessage for fallback and original toggle, copyright 2025->2026
- ExceptionResponseFactory: split rawMessage, htmlspecialchars only for
  API_MODE Json, pass raw to ErrorPage (single escape source)
- RequestFactory: copyright 2025->2026

* Clean up locale detection error handling

* refactor(interfaces): split generic Interfaces into domain colocated contracts

- Delete FOSSBilling\Interfaces catch-all (5 files)
- FOSSBilling\Interfaces\ApiArrayInterface -> FOSSBilling\Api\ArrayInterface
  (ArrayInterface, removes Api stutter, 60+ entities)
- FOSSBilling\Interfaces\InjectionAwareInterface -> FOSSBilling\Container\InjectionAwareInterface
  (Container, 100+ services)
- FOSSBilling\Interfaces\SecurityCheckInterface -> FOSSBilling\Security\CheckInterface
  (Security, removes Security stutter, 4 checks)
- FOSSBilling\Interfaces\TimestampInterface -> FOSSBilling\Doctrine\TimestampInterface
  (Doctrine, with TimestampTrait)
- FOSSBilling\Interfaces\WidgetProviderInterface -> Box\Mod\Widgets\ProviderInterface
  (Widgets module, provider contract for Branding etc)
Update 172 usages, keep InjectionAware/Timestamp short names, shorten
ApiArray/SecurityCheck/WidgetProvider stutter.

* Type logger cache as array in factory

* Update pagination copyright headers

* Update CentralAlerts copyright year

* Clean up sanitizer class imports

* Refine security helpers and enum namespace

* Update system class copyright headers

* Move Twig AppArea enum to root namespace

* Update copyright year in update classes

* Drop legacy Arr/Str wrappers and DI binding

* refactor(utils): complete Tools split and delete god class

- Delete FOSSBilling\Tools (534 lines) god class, remove load.php require/use
- Migrate 16 call sites: di['tools']->validateAndSanitizeEmail -> EmailValidator::validateAndSanitizeEmail (8 Api), News slug -> Str::slug, generatePassword already via Credential, humanReadableBytes via Utils\Format, etc.
- Remove 20 unused use FOSSBilling\Tools imports (Order/Extension/Currency etc.)
- Harden Arr::getPairsForTableByIds with preg_match table validation and backticks (CsvResponseFactory style)
- Remove Str legacy wrappers (slugLegacy/slugInstance/to_camel_case)
- Update 11 test files to remove Tools mocks (Extension/Product/Profile/Custompages/Staff/Client/Email/Invoice/Support) and Container helper
- phpstan 443 OK (one less file), tests 60/6 pre-existing failures (AbstractApi identity/pagination) remain, Tools InvalidCount 54->0 for new code

* Update RequiredParams copyright year

* Update copyright headers to 2026

* Remove dead validation code

* fix(ci): address Rector, CS-Fixer and test failures after library reorg

- Run Rector fixes: RemoveUselessReturnTag, RemoveReadonlyOnReadonlyClass, ReadOnlyProperty, RecastingRemoval
- Run cs:fix (132 files) for import ordering and formatting
- Fix Pest helper to handle Mockery mocks for Admin/Client identities
- Fix DispatcherTest to use Module mocks for extension
- Update Client tests to remove Tools mocks and use new EmailValidator/Url
- Fix Custompages ServiceTest to use real Str::slug and long titles
- Fix Pagination mocks to use Service class
- Fix i18n, InformationException, Requirements, Config namespaces
- Add url to test container with SYSTEM_URL base
- Fix a few remaining service tests (Profile, Extension, Product, Support)

phpstan 451/451 OK, pest now 11 failures (down from 107)

* fix(tests): make suite green after reorg

- Fix Extension ServiceTest InformationException namespace and Str mock
- Fix Product Api Admin paginated products mock
- Fix Client/Service i18n, Profile i18n, and url container
- Fix Custompages long-title slug handling
- All 2998 tests pass (2 skipped), phpstan 451/451

* style: fix cs

* fix(docker): update Version.php path after move to System namespace

* fix(tests): remove remaining Tools references

- Delete obsolete ToolsTest (Tools class removed)
- Remove di['tools'] mocks from Profile ServiceTest

* feat(update): add patch115 to clean up library reorg orphans

Removes stale PSR-4 paths left on disk after refactor/library-reorganization
(Tools split, moved namespaces, low-case i18n) which persist on updated
installs because the updater extracts on top. Uses executeFileActions +
removeEmptyDirectories with exists guards, idempotent.

Bumps fresh-install last_patch to 115 to match latestPatchLevel().

* fix(cache): align TLS + instance_id merge with reorg namespaces

- Use Normalizer::normalizeBoolean instead of removed Tools
- Use BaseException + driver->value for instance_id/TLS errors
- Update tests to expect BaseException

* fix(tests): use US spelling to satisfy spellcheck

Normalise -> Normalize, normalised -> normalized

* refactor(box): move Box legacy globals to semantic namespaces

- Box_App/AppAdmin/AppClient -> FOSSBilling\Http\App/AppAdmin/AppClient
- Box_Authorization -> FOSSBilling\Security\Authorization
- Box_Event/EventDispatcher/EventManager -> FOSSBilling\Event\Event/Dispatcher/Manager
- Codemod 499 references across src/modules, src/di.php, src/index.php, tests
- Fix namespace imports (ArrayObject, Pimple\Container, ReflectionMethod, \RuntimeException, \InvalidArgumentException, \Box\Mod\*, \Override)
- Add Patcher patch116 to clean up orphaned src/library/Box/* + bump last_patch 115->116

* refactor(update): extract Patcher patches to Patch/* orchestrator

- Create FOSSBilling\Update\Patch\PatchInterface + PatchRegistry (explicit MAP, not Finder)
- Move 89 private patch25..patch116 methods to dedicated classes Patch25..Patch116
- Each Patch::apply(Patcher $patcher) delegates via public helpers ($patcher->executeSql etc.)
- Patcher now shrinks from 3624 to 1049 LOC: getPatches() uses PatchRegistry::MAP, applyCorePatches() loops [new PatchClass(), 'apply'] with call_user_func($patch, $this)
- Make helpers public (executeSql, fetchAll, tableHasColumn etc.) and properties public for patch access
- Update UpdatePatcherTest to invoke Patch*::apply and expect 'apply' method, keep ordering/skip checks
- No behavior change: MySQL guard, SchemaSynchronizer, last_patch 116 intact

* fix(spellcheck): add checkperm to dictionary after Box->Http move

* style: apply Rector StringClassNameToClassConstant + typed const

* style: apply Rector IfToNullCoalescing for changed files

* style: apply remaining Rector IfToNullCoalescing

* refactor(update): drop redundant Patch101

ADD INDEX client_order_unpaid_invoice_id_idx is already declared in
Order.php:19 and covered by SchemaSynchronizer's additive sync (safeAddedIndexes).
On PG/SQLite Patch101 never ran (isMysqlDriver guard), MySQL now gets it
via syncPortableSchema after the patch loop. Keeps last_patch at 116,
adds gap 101 alongside 92/95/98.

* refactor(update): make Patch116 superset of 115+116, keep 115 as no-op gap

* fix(spellcheck): add superset/orphans after Patch115/116 merge

* refactor(update): remove Patch115 file, keep 116 as superset

Patch115 orphan cleanup is now handled by Patch116 superset (39 paths).
115 remains a gap (like 92/95/98/101) — installs at 115 still get cleanup via
116's idempotent deletes.

* refactor(update): make Patch39 superset of 38+39, drop Patch38 file

38 (Ispconfig3/Virtualmin) + 39 (srp) are idempotent file deletions.
39 now deletes all 3 orphans; 38 remains gap like 101/115.
Installs at 37 -> 39 and 38 -> 39 both get cleanup via 39 superset.

* fix(spellcheck): add Ispconfig3 for Patch39 superset

* refactor(update): make Patch33 superset of 30-33, drop Patch30-32 files

30 (guzzlehttp) + 31 (htaccess/config.old) + 32 (phpmailer/Box/*) + 33 (FileCache)
are idempotent file deletions. 33 now deletes all 19 orphans; 30-32 remain gaps
like 38/101/115. Installs at 29 -> 33 and 32 -> 33 both get cleanup via 33 superset.

* fix(spellcheck): add idempotently/gaps/FileCache for Patch33 superset

* refactor(update): move gateway/branding helpers into Patch64

- migrateDefaultBrandingAssetsToPublicDirectory and
  migrateGatewayAssetsToPublicDirectory were only used by Patch64.
  Move them as private helpers inside Patch64 (param Patcher $patcher)
  and remove public versions from Patcher.
- Patcher shrinks by ~66 LOC, stays public only for shared helpers.

* Drop getVersion from update patches

* refactor(update): move downloadable helpers into Patch65

- generateDownloadableStoredFilename, copyLegacyDownloadableFile,
  migrateDownloadableProduct/Service/OrderStorageKeys were only used
  by Patch65 (and internally). Move them as private helpers inside
  Patch65 (private array $downloadableStorageMigrationMap + 5 methods
  taking Patcher $patcher) and remove public versions + property from
  Patcher. Keeps Patcher public API for shared helpers only.

* refactor(update): move email template helpers into Patch52

- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
  used by Patch52 directly (and via Patcher::restoreLegacy... for 57/90).
  Move them as private helpers inside Patch52 (taking Patcher $patcher)
  so Patch52 is self-contained. Keep public versions in Patcher for
  restoreLegacyDefaultEmailTemplates used by Patch57/90 (duplicate for now,
  could also be moved if desired).

* refactor(update): move email template helpers into Patch52/57/90

- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
  used by Patch52 direct and via Patcher::restoreLegacyDefaultEmailTemplates
  for Patch57/90. Move them as private helpers into Patch52 (self-contained)
  and duplicate into Patch57/90 alongside private
  restoreLegacyDefaultEmailTemplates (which also moved from Patcher).
- Remove public versions from Patcher — keeps Patcher public API for shared
  helpers only (executeSql, tableHasColumn etc.). Fixes unrelated
  Patcher.php diff (\s vs \s) by removing the methods entirely.

* refactor(update): move custom page slug helpers into Patch102

- allocateUniqueCustomPageSlug and fitCustomPageSlug were only used
  by Patch102 (via $patcher->). Move them as private helpers inside
  Patch102 (taking Patcher $patcher, using $patcher->fetchOne and
  $this->fitCustomPageSlug) and remove public versions from Patcher.
  Keeps Patcher public API for shared helpers only.

* refactor(update): make Patcher helpers and patch bookkeeping driver-agnostic

- applyConfigPatches: normalize driver aliases (mysql/pgsql/sqlite -> pdo_*) and use per-driver port defaults (5432 for pgsql, none for sqlite) instead of hardcoded 3306; aligns with DriverManagerFactory::DEFAULT_PORTS.
- setPatchLevel: replace MySQL-only ON DUPLICATE KEY UPDATE with portable SELECT then UPDATE/INSERT so last_patch works on pgsql/sqlite.
- Rename isMysqlDriver -> isLegacyPatchDriver (keep isMysqlDriver as deprecated alias); availablePatches/applyCorePatches now use isLegacyPatchDriver; update SchemeSynchronizer and test docblocks.
- Make tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex portable via AbstractSchemaManager + driver-specific fallbacks (information_schema.tables WHERE table_schema='public' for pgsql, sqlite_master for sqlite); MySQL path keeps SHOW COLUMNS/INDEX for BC.
- updateTable: use driver-aware quoting (backticks for mysql, double quotes for pgsql/sqlite) instead of hardcoded backticks.
Verified: phpstan 536/536, cs:fix clean, pest 2990 passed.

* chore: add pg catalog words to spellcheck dictionary

* Remove deprecated compatibility shims

* refactor(doctrine): expand Driver enum and use it throughout

- Driver.php: add PdoPgsql/PdoSqlite cases, tryFromAlias(), defaultPort(), isMysql/isSqlite helpers; was single-case.
- DriverManagerFactory.php: use Driver::tryFromAlias() in normalizeDriver(), Driver::tryFrom()->defaultPort()/isMysql/isSqlite in getDatabaseConfig/buildConnectionParams/applySessionSettings instead of string compares.
- Patcher.php: use Driver enum in applyConfigPatches (alias + defaultPort), isLegacyPatchDriver (isMysql), tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex/updateTable (portable via AbstractSchemaManager + enum driver checks).
Ensures single source of truth for supported drivers; phpstan 536/536, pest 2990.

* Unify HTML sanitization across Twig and APIs

Replace the old sanitizer helpers with a shared HtmlSanitizerFactory and reuse context-specific sanitizers for content, markdown, adapter output, and theme settings. This also exposes sanitize_html and sanitize_markdown Twig filters, sanitizes markdown render output and email/signature HTML consistently, and simplifies support/email input cleanup by trimming/null-byte stripping instead of HTML-encoding raw input.

* fix: align PayGateway entities with Doctrine TimestampInterface after merge

* Type HTML sanitizer scheme constants

* style: fix import order and FQCN usage

* refactor(sanitizer): centralize HtmlSanitizerFactory sanitization and remove redundant config

* fix: add commonmark to cspell dictionary

* fix: apply Rector autofixes for library reorganization

Addresses Code Quality failures after merging main:
- remove unnecessary (string)/(bool) recasts (RecastingRemovalRector)
- add strict string casts for null-able args (NullToStrictStringFuncCallArgRector)

Files are limited to the PR's changed PHP set (BASE_SHA...HEAD) so
pre-existing drift elsewhere doesn't block this branch.

* fix: stabilize DomainValidator arrow function type for Rector

Add explicit string type to array_filter callback to prevent
oscillation between NullToStrictString (add cast) and
RecastingRemoval (remove cast) on the tld parameter.

* fix: apply Rector autofixes after merge from main

BinaryOpNullableToInstanceof / WhileNullableToInstanceof

* style: fix CS imports after Rector (Invoice / ServiceLicense FQCN -> imported)

* fix: use FOSSBilling\Event\Event in Antispam isSpam test (Box_Event -> Event reorg)

* Move src/library/FOSSBilling to src/core

First-party namespaced classes now live in src/core, leaving src/library
as the legacy PSR-0 adapter layer (Payment_, Registrar_, Server_).

- composer.json: PSR-4 FOSSBilling\ -> src/core/ (classmap unchanged)
- load.php/install.php: new PATH_CORE constant; pre-autoload requires repointed
- Update/ReadinessCheck: core/ added to the writable-folder checks
- Patch117: removes the obsolete library/FOSSBilling tree on upgraded installs
- Dockerfile: copies src/core in the vendor stages; release rewrites
  (Version.php, SentryHelper.php, archive composer remap) repointed
- labeler/CODEOWNERS/cspell/AGENTS.md/test path references updated
- install/sql/content.sql: last_patch seed 116 -> 117

* Rename the FOSSBilling\ namespace to FOSSBilling\Core\

All first-party namespaced classes now live under FOSSBilling\Core\,
matching their src/core location. Clean cut, consistent with the Box\
removal on this branch: released extensions referencing the old names
must update their imports once in the same release.

- namespace/use/FQCN/docblock rewrite across src/ and tests/ (626 files)
- composer.json PSR-4: FOSSBilling\Core\ -> src/core/
- Dockerfile release composer remap updated to match
- SentryHelper: core.class tag for core paths, library.class retained
  for the PSR-0 adapter layer
- Doctrine proxy namespaces (prod + test) follow the new prefix
- tests/Unit/FOSSBilling -> tests/Unit/FOSSBilling/Core; template
  harness dirname depths adjusted
- ignore Strict/ test-harness findings artifacts

* Refresh the Composer autoloader after restoring the cached vendor tree

The php-build vendor cache is keyed on composer.lock, whose content-hash
does not change for autoload-only composer.json edits. On a cache hit the
install step is skipped entirely, leaving vendor/autoload.php generated
from the old PSR-4 mapping - so the library-to-core reorganization made
Rector fatal with "Trait FOSSBilling\Core\Doctrine\TimestampTrait not
found" while compiling analyzed files. Re-dump the autoloader from the
current composer.json after the cache restore.

* Trim comments on the two hand-written pieces to house style

Patch116 uses two dense lines with a short SHA rather than a commit
title; mirror that for Patch117. Shorten the autoloader-refresh comment
in php-quality-setup to the essentials.

* Fix two stale references from the library-to-core move

- GeoIP\Reader still built its bundled MMDB paths from PATH_LIBRARY
  . FOSSBilling - point them at PATH_CORE so the default reader works
  now that Patch117 removes the old tree; add existence tests so a
  future move can't silently break this again.
- Dispatcher passed Request::getClientIp() (nullable on CLI, e.g. cron)
  to the string-typed setIp(), TypeErroring before the first cron task;
  coalesce to '' to match AbstractApi's own empty-string default.

* Dockerfile: namespace-wrapped cache sanity check belongs under Core

* Add dasharray to cspell words (SVG stroke-dasharray in branding assets)

Fixes Spellcheck failure on the push CI run.

* Deslop merge resolutions: trim comments, drop dead test stubs, import Patch classes

* Fix stale old-namespace references found in completeness audit

- CONTRIBUTING.md documented src/library/FOSSBilling paths/namespaces
- EntityMigrationCoverageTest read the deleted
  library/FOSSBilling/UpdatePatcher.php; scan Patcher.php + Patch
  classes instead (proven to detect ported migrations)

* Satisfy Rector 2.8: drop redundant cast, add nullable return type

* Address code review: restore Patch30-32, trim Patch33, drop retired hook call in Patch55

- Patch55 called mod_service('hook')->batchConnect(), removed on main
  when the hook runtime was retired; the throw skipped the remaining
  spamchecker cleanup. Match main (no hook lines).
- Patch33 had absorbed patches 30-32 cleanups, newly deleting the live
  vendor/guzzlehttp (now required via sentry/sentry) for installs past
  patch 30. Restore Patch30/31/32 as separate classes matching main and
  trim Patch33 to the FileCache.php removal.

* Fix uninitialized Arr helper for group-restricted promos

enrichPromoApiArray built Arr without a container, so any promotion
with client-group restrictions fatally dereferenced null di in
getPairsForTableByIds. Initialize the helper with the service
container (with the file's usual missing-container guard) and add a
regression test.

* Keep client-group lookup portable: drop MySQL backticks

The Tools split introduced backtick quoting around the validated
table name; PostgreSQL rejects it. Main used the validated unquoted
name - restore that and assert the exact SQL in the regression test.
2026-11-01 22:15:41 +00:00

419 lines
13 KiB
PHP

<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0
*/
declare(strict_types=1);
namespace Tests\Support;
use FOSSBilling\Core\Twig\Extension\ApiExtension;
use FOSSBilling\Core\Twig\Extension\FOSSBillingExtension;
use FOSSBilling\Core\Twig\Extension\LegacyExtension;
use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Extension\AttributeExtension;
use Twig\Extension\CoreExtension;
use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\Binary\NullCoalesceExpression;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\TestExpression;
use Twig\Node\ForNode;
use Twig\Node\Node;
use Twig\Node\SetNode;
use Twig\NodeTraverser;
use Twig\NodeVisitor\NodeVisitorInterface;
/**
* Static analysis pass for FOSSBilling Twig templates.
*
* Walks the AST of every template and flags attribute accesses (x.y, x.y.z,
* x['y']) that are not guarded by `is defined`, `?? defaultValue`, or
* `| default(...)`.
*
* The render-everything strict-variables test catches the same class of bug
* at render time, but only for code paths actually exercised in the harness.
* This linter covers the rest by walking the AST: partials that are included
* from other templates, conditional branches, etc. It runs much faster than
* the render pass and gives stable, file:line-anchored findings.
*
* The output is intended to be diffed against a baseline file
* (`tests/Strict/ast-linter-baseline.json`) so the test only fails on
* regressions, not on existing patterns.
*/
final class TwigAstLinter
{
/**
* Globals that are always defined at template render time. Attribute
* accesses whose root is one of these are exempt from the linter because
* the render harness guarantees their presence.
*
* @var list<string>
*/
public const array KNOWN_GLOBALS = [
'app_area',
'current_theme',
'CSRFToken',
'FOSSBillingVersion',
'default_currency',
'theme',
'request',
'request_query',
'request_path',
'request_has_filters',
'settings',
'admin',
'client',
'guest',
'mf',
];
/**
* @return list<array{file: string, line: int, snippet: string, root: string}>
*/
public function lint(): array
{
$env = $this->buildEnvironment();
$findings = [];
foreach ($this->findTemplates() as $path) {
$findings = array_merge($findings, $this->lintTemplate($env, $path));
}
return $findings;
}
private function buildEnvironment(): Environment
{
$loader = new CombinedTwigLoader(PATH_THEMES);
$env = new Environment($loader, [
'strict_variables' => true,
'auto_reload' => true,
'cache' => false,
'debug' => false,
]);
// Register the FOSSBilling extensions so the parser can resolve custom
// filters (`trans`, `url`, ...) and functions (`has_permission`, ...)
// — without these, every template that uses them throws at parse time
// and the linter silently skips the whole file.
$env->getExtension(CoreExtension::class);
$env->addExtension(new AttributeExtension(ApiExtension::class));
$env->addExtension(new AttributeExtension(FOSSBillingExtension::class));
$env->addExtension(new AttributeExtension(LegacyExtension::class));
return $env;
}
/**
* @return list<string>
*/
private function findTemplates(): array
{
$paths = [];
$finder = new Finder();
$finder->files()->in(PATH_MODS)->name('*.html.twig');
foreach ($finder as $file) {
$paths[] = $file->getPathName();
}
$finder = new Finder();
$finder->files()->in(PATH_THEMES)->name('*.html.twig');
foreach ($finder as $file) {
$paths[] = $file->getPathName();
}
sort($paths);
return $paths;
}
/**
* @return list<array{file: string, line: int, snippet: string, root: string}>
*/
private function lintTemplate(Environment $env, string $path): array
{
$source = (string) file_get_contents($path);
try {
$stream = $env->tokenize(new \Twig\Source($source, $path));
$nodes = $env->parse($stream);
} catch (\Throwable) {
// Parser errors are surfaced by StrictVariablesTest, not here.
return [];
}
$visitor = new LinterVisitor($path, $source);
$traverser = new NodeTraverser($env, [$visitor]);
$traverser->traverse($nodes);
return $visitor->getFindings();
}
}
/**
* @internal used by {@see TwigAstLinter}
*/
final class LinterVisitor implements NodeVisitorInterface
{
/**
* @var list<array{file: string, line: int, snippet: string, root: string}>
*/
private array $findings = [];
/** @var list<Node> Stack of ancestor nodes (oldest at index 0). */
private array $stack = [];
/**
* Names of variables known to be defined at the current AST position
* (globals, `{% set %}` targets, `{% for %}` loop variables, embed/include
* `with {…}` keys, …). GetAttrExpression whose root matches one of these
* is exempt from the linter.
*
* Implemented as a stack of scopes so a `{% set foo %}` inside an `{% if %}`
* doesn't leak to siblings after the if-block.
*
* @var list<array<string, true>>
*/
private array $locals = [];
public function __construct(
private readonly string $file,
private readonly string $source,
) {
}
public function enterNode(Node $node, Environment $env): Node
{
if ($node instanceof SetNode) {
// `{% set x = … %}` — record x as defined in the enclosing scope.
$names = $this->collectSetNames($node);
if (!empty($names)) {
$top = end($this->locals);
if ($top === false) {
$this->locals[] = array_fill_keys($names, true);
} else {
foreach ($names as $name) {
$top[$name] = true;
}
$this->locals[count($this->locals) - 1] = $top;
}
}
} elseif ($node instanceof ForNode) {
// `{% for k, v in … %}` — record k, v (and `loop`).
$this->locals[] = ['loop' => true];
$target = $node->getNode('key_target');
if ($target instanceof AssignNameExpression) {
$name = $target->getAttribute('name');
if (is_string($name) && $name !== '' && !str_starts_with($name, '_')) {
$top = end($this->locals);
$top[$name] = true;
$this->locals[count($this->locals) - 1] = $top;
}
}
$valueTarget = $node->getNode('value_target');
if ($valueTarget instanceof AssignNameExpression) {
$name = $valueTarget->getAttribute('name');
if (is_string($name) && $name !== '' && !str_starts_with($name, '_')) {
$top = end($this->locals);
$top[$name] = true;
$this->locals[count($this->locals) - 1] = $top;
}
}
}
if (!$node instanceof GetAttrExpression) {
$this->stack[] = $node;
return $node;
}
$root = $this->resolveRootName($node);
if ($root !== null && $this->isKnownName($root)) {
$this->stack[] = $node;
return $node;
}
if (!$this->isGuarded($node)) {
$line = $node->getTemplateLine();
$this->findings[] = [
'file' => $this->file,
'line' => $line,
'snippet' => $this->extractLine($line),
'root' => $root ?? '?',
];
}
$this->stack[] = $node;
return $node;
}
public function leaveNode(Node $node, Environment $env): Node
{
array_pop($this->stack);
if ($node instanceof ForNode) {
array_pop($this->locals);
}
return $node;
}
/**
* Collect target names from a SetNode, which can carry multiple
* assignments in one statement: `{% set a, b = foo, bar %}`.
*
* @return list<string>
*/
private function collectSetNames(SetNode $node): array
{
$names = [];
$namesNode = $node->getNode('names');
foreach ($namesNode as $child) {
if ($child instanceof AssignNameExpression) {
$name = $child->getAttribute('name');
if (is_string($name) && $name !== '' && !str_starts_with($name, '_')) {
$names[] = $name;
}
}
}
return $names;
}
public function getPriority(): int
{
return 0;
}
/**
* Check whether $name is known to be defined in the current scope
* (a render global or a local introduced by `{% set %}`/`{% for %}`).
*/
private function isKnownName(string $name): bool
{
if (in_array($name, TwigAstLinter::KNOWN_GLOBALS, true)) {
return true;
}
foreach ($this->locals as $scope) {
if (isset($scope[$name])) {
return true;
}
}
return false;
}
/**
* @return list<array{file: string, line: int, snippet: string, root: string}>
*/
public function getFindings(): array
{
return $this->findings;
}
/**
* Walk up the GetAttrExpression chain and return the root variable name
* if it is a simple `name`/`name.attr`/`name.attr.attr` access.
*/
private function resolveRootName(GetAttrExpression $node): ?string
{
$current = $node;
while ($current instanceof GetAttrExpression) {
$attr = $current->getNode('attribute');
if (!$attr instanceof \Twig\Node\Expression\ConstantExpression) {
return null;
}
$current = $current->getNode('node');
}
if ($current instanceof \Twig\Node\Expression\NameExpression) {
return $current->getAttribute('name');
}
return null;
}
/**
* Check whether this GetAttrExpression is wrapped in a guard expression:
* - `is defined` (positive only)
* - `?? defaultValue` (left side)
* - `| default(...)` (immediate parent filter)
*/
private function isGuarded(GetAttrExpression $node): bool
{
// When enterNode is called for $node, $node is NOT yet in the stack —
// the top of the stack is the parent. Use that directly to start the
// walk; for subsequent ancestors, search the stack for the current node.
$current = end($this->stack);
if ($current === false) {
return false;
}
$child = $node;
while ($current !== null) {
if ($current instanceof TestExpression) {
if ($current->getNode('node') === $child) {
$name = $current->getAttribute('name');
if ($name === 'defined') {
// `is defined`: guarded. `is not defined`: not guarded.
// The `not` attribute only exists when the test is
// negated (e.g. `is not defined`).
$isNegated = $current->hasAttribute('not') && $current->getAttribute('not');
if (!$isNegated) {
return true;
}
}
}
}
if ($current instanceof NullCoalesceExpression) {
if ($current->getNode('left') === $child) {
return true;
}
}
if ($current instanceof FilterExpression) {
if ($current->getNode('node') === $child) {
$name = $current->getAttribute('name');
if ($name === 'default') {
return true;
}
}
}
$next = $this->parentOf($current);
$child = $current;
$current = $next;
}
return false;
}
/**
* Find the parent of $node in the visitor's ancestor stack.
*/
private function parentOf(Node $node): ?Node
{
$count = count($this->stack);
for ($i = $count - 1; $i > 0; --$i) {
if ($this->stack[$i] === $node) {
return $this->stack[$i - 1];
}
}
return null;
}
private function extractLine(int $line): string
{
$lines = explode("\n", $this->source);
if (isset($lines[$line - 1])) {
return trim($lines[$line - 1]);
}
return '';
}
}