mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-03 02:27:21 +09:00
* 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.
508 lines
19 KiB
PHP
508 lines
19 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-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\Extra\Intl\IntlExtension;
|
|
use Twig\Extra\Markdown\MarkdownExtension;
|
|
use Twig\Extra\Markdown\MarkdownInterface;
|
|
use Twig\Extra\Markdown\MarkdownRuntime;
|
|
use Twig\NodeTraverser;
|
|
|
|
class UrlAwarePermissiveContainer extends \Pimple\Container
|
|
{
|
|
private readonly PermissiveStub $stub;
|
|
/** @var array<string, mixed> */
|
|
private array $store;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
$this->stub = new PermissiveStub();
|
|
$this->store['loaded_assets'] = [];
|
|
$this['filesystem'] = new \Symfony\Component\Filesystem\Filesystem();
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetExists(mixed $offset): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetGet(mixed $offset): mixed
|
|
{
|
|
if ($offset === 'url') {
|
|
return new class {
|
|
public function link(string $path, ?array $query = null): string
|
|
{
|
|
return '';
|
|
}
|
|
|
|
public function adminLink(string $path, ?array $query = null): string
|
|
{
|
|
return '';
|
|
}
|
|
};
|
|
}
|
|
// The LegacyExtension's `ip_country_name`/`ip_country_code` filters
|
|
// call `$this->di['geoip']->country($ip)` and then read properties on
|
|
// the returned record. The signatures are `: string`, so a permissive
|
|
// stub would fail with a TypeError. Provide a stub object whose
|
|
// `country()` call throws — the surrounding try/catch then returns
|
|
// the empty-string fallback the real code emits when GeoIP is
|
|
// unavailable. Mirrors the same handling in `PermissiveContainer`.
|
|
if ($offset === 'geoip') {
|
|
return new class {
|
|
public function __call(string $name, array $args): mixed
|
|
{
|
|
throw new \RuntimeException('geoip service not available in test environment');
|
|
}
|
|
|
|
public function __get(string $name): mixed
|
|
{
|
|
throw new \RuntimeException('geoip service not available in test environment');
|
|
}
|
|
};
|
|
}
|
|
if (array_key_exists((string) $offset, $this->store)) {
|
|
return $this->store[(string) $offset];
|
|
}
|
|
|
|
return $this->stub;
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetSet(mixed $offset, mixed $value): void
|
|
{
|
|
$this->store[(string) $offset] = $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper that builds a strict-variables Twig environment and renders every template
|
|
* with a permissive stub context, capturing any rendering errors.
|
|
*
|
|
* Used by StrictVariablesTest to enforce that all templates are safe to render under
|
|
* `strict_variables => true`. The permissive stub absorbs every undefined variable, attribute,
|
|
* method, and array key, so what remains is structural errors: syntax errors, missing macros,
|
|
* missing parents/blocks, undefined filters/functions, and runtime errors raised by filters.
|
|
*/
|
|
final class StrictTemplateRenderer
|
|
{
|
|
private function suppressNoticesAndWarnings(int $reportingLevel): int
|
|
{
|
|
return $reportingLevel & ~E_NOTICE & ~E_WARNING;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $contextOverrides
|
|
*/
|
|
public function renderTemplate(string $templatePath, array $contextOverrides = [], bool $emailMode = false): string
|
|
{
|
|
$previousLevel = error_reporting();
|
|
error_reporting($this->suppressNoticesAndWarnings($previousLevel));
|
|
|
|
try {
|
|
$twig = $this->buildEnvironment($emailMode, stringifyUrls: true);
|
|
$context = array_replace($this->buildContext($emailMode), $contextOverrides);
|
|
|
|
try {
|
|
$context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode);
|
|
$context = array_replace($context, $contextOverrides);
|
|
} catch (\Throwable) {
|
|
// Let the render call surface any meaningful parser/loader error.
|
|
}
|
|
|
|
return $twig->render($this->relativeTemplateName($templatePath), $context);
|
|
} finally {
|
|
error_reporting($previousLevel);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array{file: string, template: string, error: string, category: string}>
|
|
*/
|
|
public function renderAllTemplates(bool $emailMode = false): array
|
|
{
|
|
$findings = [];
|
|
|
|
// Suppress E_NOTICE/E_WARNING only for the duration of this harness.
|
|
// The render-everything pass routinely coerces a PermissiveStub to a
|
|
// scalar (e.g. `|format_currency` on a stub amount, or arithmetic on a
|
|
// stub count), which raises PHP notices that aren't bugs in the
|
|
// templates themselves. Restoring the previous level on the way out
|
|
// keeps unrelated tests in the suite under their normal reporting
|
|
// settings.
|
|
$previousLevel = error_reporting();
|
|
error_reporting($this->suppressNoticesAndWarnings($previousLevel));
|
|
|
|
try {
|
|
$twig = $this->buildEnvironment($emailMode);
|
|
|
|
$templates = $emailMode ? $this->discoverEmailTemplates() : $this->discoverAllTemplates();
|
|
|
|
foreach ($templates as $templatePath) {
|
|
$relative = $this->relativeTemplateName($templatePath);
|
|
$context = $this->buildContext($emailMode);
|
|
|
|
// Pre-populate the context with stubs for every variable referenced in
|
|
// the template. This lets partials (which expect parent-passed
|
|
// variables) render successfully in isolation.
|
|
try {
|
|
$context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode);
|
|
} catch (\Throwable) {
|
|
// If we can't parse the template, fall through to render and let
|
|
// the loader/parser surface a meaningful error.
|
|
}
|
|
|
|
try {
|
|
$twig->render($relative, $context);
|
|
} catch (\Throwable $e) {
|
|
$findings[] = [
|
|
'file' => $templatePath,
|
|
'template' => $relative,
|
|
'error' => $this->summarizeError($e),
|
|
'category' => $this->categorizeError($e),
|
|
];
|
|
}
|
|
}
|
|
} finally {
|
|
error_reporting($previousLevel);
|
|
}
|
|
|
|
return $findings;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function enrichContextWithTemplateVariables(Environment $twig, string $templatePath, array $context, bool $emailMode): array
|
|
{
|
|
$source = new \Twig\Source((string) file_get_contents($templatePath), $this->relativeTemplateName($templatePath));
|
|
|
|
try {
|
|
$stream = $twig->tokenize($source);
|
|
$nodes = $twig->parse($stream);
|
|
} catch (\Throwable) {
|
|
return $context;
|
|
}
|
|
|
|
$visitor = new VariableCollectorVisitor();
|
|
$traverser = new NodeTraverser($twig, [$visitor]);
|
|
$traverser->traverse($nodes);
|
|
|
|
$stub = new PermissiveStub();
|
|
foreach ($visitor->getVariableNames() as $name) {
|
|
// Don't overwrite globals already provided in the context.
|
|
if (!array_key_exists($name, $context)) {
|
|
$context[$name] = $stub;
|
|
}
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
private function buildEnvironment(bool $emailMode, bool $stringifyUrls = false): Environment
|
|
{
|
|
$loader = new CombinedTwigLoader(PATH_THEMES);
|
|
// Also register every module template directory under the unnamed path
|
|
// so unqualified `{% include 'mod_foo.html.twig' %}` calls resolve.
|
|
// CombinedTwigLoader already registers them under `@Module_area`
|
|
// namespaces; this mirrors that for bare-name lookups.
|
|
$finder = new Finder();
|
|
$finder->directories()->in(PATH_MODS)->depth('== 2')->ignoreDotFiles(true)->name(['admin', 'client', 'email']);
|
|
foreach ($finder as $dir) {
|
|
$loader->addPath($dir->getPathName());
|
|
}
|
|
$twig = new Environment($loader, [
|
|
'strict_variables' => true,
|
|
'auto_reload' => true,
|
|
'cache' => false,
|
|
'debug' => false,
|
|
]);
|
|
|
|
$twig->getExtension(CoreExtension::class)->setNumberFormat(2, '.', '');
|
|
$twig->getExtension(CoreExtension::class)->setTimezone('UTC');
|
|
|
|
$dateFormatter = new \IntlDateFormatter('en', \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, 'UTC');
|
|
$twig->addExtension(new IntlExtension($dateFormatter));
|
|
$twig->addExtension(new MarkdownExtension());
|
|
|
|
$twig->addExtension(new AttributeExtension(ApiExtension::class));
|
|
$twig->addExtension(new AttributeExtension(FOSSBillingExtension::class));
|
|
$twig->addExtension(new AttributeExtension(LegacyExtension::class));
|
|
|
|
// Stub the debug bar functions so admin layout templates that reference
|
|
// `{{ debug_bar_render_head() }}` and `{{ debug_bar_render() }}` parse
|
|
// and render in the test environment.
|
|
$twig->addFunction(new \Twig\TwigFunction('debug_bar_render_head', fn (): string => ''));
|
|
$twig->addFunction(new \Twig\TwigFunction('debug_bar_render', fn (): string => ''));
|
|
|
|
// Runtime loader: provides stub markdown + FOSSBilling/Legacy/Api
|
|
// extension instances so filter/function method dispatch works in the
|
|
// test environment. A PermissiveContainer absorbs every `$di['x']`
|
|
// access so the extensions can render without a live DI graph.
|
|
$di = $stringifyUrls ? $this->buildUrlAwareContainer() : new PermissiveContainer();
|
|
$twig->addRuntimeLoader(new readonly class($di) implements \Twig\RuntimeLoader\RuntimeLoaderInterface {
|
|
public function __construct(private \Pimple\Container $di)
|
|
{
|
|
}
|
|
|
|
public function load($class)
|
|
{
|
|
if ($class === MarkdownRuntime::class) {
|
|
return new MarkdownRuntime(new class implements MarkdownInterface {
|
|
public function convert(string $body): string
|
|
{
|
|
return $body;
|
|
}
|
|
});
|
|
}
|
|
|
|
if ($class === FOSSBillingExtension::class) {
|
|
return new FOSSBillingExtension($this->di);
|
|
}
|
|
|
|
if ($class === LegacyExtension::class) {
|
|
return new LegacyExtension($this->di);
|
|
}
|
|
|
|
if ($class === ApiExtension::class) {
|
|
return new ApiExtension($this->di);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
$context = $this->buildContext($emailMode);
|
|
foreach ($context as $name => $value) {
|
|
$twig->addGlobal($name, $value);
|
|
}
|
|
|
|
return $twig;
|
|
}
|
|
|
|
private function buildUrlAwareContainer(): \Pimple\Container
|
|
{
|
|
return new UrlAwarePermissiveContainer();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildContext(bool $emailMode): array
|
|
{
|
|
$stub = new PermissiveStub();
|
|
|
|
if ($emailMode) {
|
|
// Email templates have a different `guest` shape with precomputed globals.
|
|
return [
|
|
'request' => $stub,
|
|
'request_query' => $stub,
|
|
'request_path' => '/',
|
|
'request_has_filters' => false,
|
|
'CSRFToken' => 'test',
|
|
'FOSSBillingVersion' => '0.0.0',
|
|
'default_currency' => 'USD',
|
|
'app_area' => 'email',
|
|
'current_theme' => 'default/admin',
|
|
'theme' => ['code' => 'default/admin', 'name' => 'default/admin', 'url' => '/themes/default/admin/'],
|
|
'settings' => $stub,
|
|
'guest' => [
|
|
'system_company' => [
|
|
'name' => 'Test Co',
|
|
'signature' => '',
|
|
'favicon_url' => '',
|
|
],
|
|
'system_email' => [
|
|
'signature' => '',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'request' => $stub,
|
|
'request_query' => $stub,
|
|
'request_path' => '/',
|
|
'request_has_filters' => false,
|
|
'CSRFToken' => 'test',
|
|
'FOSSBillingVersion' => '0.0.0',
|
|
'default_currency' => 'USD',
|
|
'app_area' => 'admin',
|
|
'current_theme' => 'default/admin',
|
|
'theme' => ['code' => 'default/admin', 'name' => 'default/admin', 'url' => '/themes/default/admin/'],
|
|
'settings' => $stub,
|
|
'admin' => $stub,
|
|
'client' => $stub,
|
|
'guest' => $stub,
|
|
'mf' => $stub, // Most templates import this; let missing macros surface as errors
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function discoverAllTemplates(): array
|
|
{
|
|
$paths = [];
|
|
|
|
// Module templates
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_MODS)->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
|
|
// Theme templates
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_THEMES)->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function discoverEmailTemplates(): array
|
|
{
|
|
$paths = [];
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_MODS)->path('templates/email')->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
private function relativeTemplateName(string $absolutePath): string
|
|
{
|
|
// Module templates live under `modules/<Module>/templates/<area>/` and
|
|
// are referenced as `@<Module>_<area>/<basename>` (underscore join
|
|
// because FilesystemLoader splits `@ns/template` on the first '/' and
|
|
// rejects '/' in namespace names). Theme templates live under
|
|
// `themes/<code>/html/` and are referenced by basename.
|
|
if (str_starts_with($absolutePath, PATH_MODS . DIRECTORY_SEPARATOR)) {
|
|
$relative = substr($absolutePath, strlen(PATH_MODS . DIRECTORY_SEPARATOR));
|
|
$parts = explode(DIRECTORY_SEPARATOR, $relative, 4);
|
|
if (count($parts) === 4) {
|
|
$module = $parts[0];
|
|
$area = $parts[2]; // skip 'templates' dir
|
|
|
|
return '@' . $module . '_' . $area . '/' . basename($parts[3]);
|
|
}
|
|
}
|
|
|
|
return basename($absolutePath);
|
|
}
|
|
|
|
private const int MAX_ERROR_MESSAGE_LENGTH = 250;
|
|
|
|
private function summarizeError(\Throwable $e): string
|
|
{
|
|
$message = $e->getMessage();
|
|
if (strlen($message) > self::MAX_ERROR_MESSAGE_LENGTH) {
|
|
$message = substr($message, 0, self::MAX_ERROR_MESSAGE_LENGTH) . '...';
|
|
}
|
|
|
|
return $e::class . ': ' . $message;
|
|
}
|
|
|
|
/**
|
|
* Classify a render error as either a real template bug or a test
|
|
* infrastructure issue. The strict-variables test fails only on
|
|
* `real-bug` findings; `test-infra` and `loader` issues are informational
|
|
* because the permissive container/stub setup cannot satisfy strict
|
|
* return-type hints or lookup every real template path.
|
|
*/
|
|
private function categorizeError(\Throwable $e): string
|
|
{
|
|
$message = $e->getMessage();
|
|
|
|
if ($e instanceof \Twig\Error\LoaderError) {
|
|
// LoaderErrors during render are usually caused by child templates
|
|
// (e.g. `partial_embed_styles.html.twig`) being looked up in the
|
|
// current namespace rather than the main one. This is a quirk of
|
|
// the render-everything harness, not a real template bug.
|
|
return 'test-infra';
|
|
}
|
|
|
|
if ($e instanceof \Twig\Error\SyntaxError) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
if (str_contains($message, 'Variable ') && str_contains($message, ' does not exist')) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
if (str_contains($message, 'Key "') && str_contains($message, ' does not exist')) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
// Test infrastructure issues: type mismatches caused by PermissiveStub
|
|
// being returned where a typed return is expected (string, array, etc.),
|
|
// and method-on-null errors caused by stub methods that don't match real
|
|
// service signatures.
|
|
if (str_contains($message, 'Return value must be of type')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'on null')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'Unable to load')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'must be of type float') || str_contains($message, 'must be of type int')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'Unsupported operand types') && str_contains($message, PermissiveStub::class)) {
|
|
// Templates that do arithmetic directly on two attribute lookups
|
|
// (e.g. `order.total - order.discount`) hit this when both sides
|
|
// resolve to PermissiveStub instead of a real number. PHP objects
|
|
// don't support arithmetic operators, so this is a stub
|
|
// limitation, not a template bug. The class-name check keeps a
|
|
// genuine "Unsupported operand types" bug elsewhere in a
|
|
// template (unrelated to the stub) classified as real-bug.
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'NumberFormatter::formatCurrency')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'is not callable')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'is not iterable')) {
|
|
return 'test-infra';
|
|
}
|
|
|
|
return 'real-bug';
|
|
}
|
|
}
|