224 Commits
Author SHA1 Message Date
Adam Daley 937360f171 Lock module manifests to spec v1 (#4619)
* Lock module manifests to spec v1

Backfill all module manifests to the locked v1 format:
manifest_version/type/id/name/version/description/author/license/
homepage_url/icon_url/requires. Versions align to the release
version, stamped at build time in the release-tree stage alongside
Version::VERSION and verified by the release workflow.

Brand mirrors FOSSBilling/modules byte-identically; adds a
vendored manifest validator (tools/manifest) run in CI on every
module manifest, and aligns content.sql seed versions with the
manifest versions.

Module manifest v1 (summary):
- manifest_version: 1, type: module
- requires as composer/semver ranges (fossbilling, php, modules)
- SPDX license expressions
- author as {name, url}

* Drop redundant recast flagged by Rector

* Format manifest validator to repo style

* Address manifest PR review: normalize v1 author/project_url, harden validator

* Enforce v1 manifest requires constraints at install time

Check requires.fossbilling (skipped on preview builds where the
running version is a placeholder), requires.php, and
requires.modules (presence plus installed version) in
installModule() before any install hook runs. Manifests without a
requires object pass through unchanged. Promotes composer/semver
to a production dependency and adds activate() unit coverage for
satisfied, unmet, missing-dependency, and invalid-constraint cases.

* Use spellcheck-clean fixture names and wording

* Review validator feedback: placeholder message style, guard non-array requires.modules

* Update job name to 'Validate Manifests'

* Resolve review: missing constraint placeholder, name-only author link

* Review follow-ups: nameless author fallback, core dependency version test

* Use spellcheck-clean fixture name

* Use neutral author fallback for nameless v1 authors

* Point requires enforcement at ExtensionType after enum merge

* Sync legacy seeded module versions on update

* Guard version sync on partial schemas, bump seeded patch level

* Allow version-sync SQL in strict patcher test doubles

* Address review: strict requires, preview parsing, manifest versions, drift-path sync

* Remove duplicated requires tests

* Use spellcheck-clean wording in docblock

* Address review: strict requires, preview parsing, manifest versions, drift sync

* Reject unversioned dependencies at install time

* Spellcheck-clean fixture name

* Block deactivation of modules with installed dependents

* Strict prerelease validation; seeded-sync integration test

* Address review: strict requires, preview parsing, manifest-gated sync, semver inputs

* Review followups: spellcheck-clean fixtures, integration test mock scope

* Deslop: clarify deactivate guard scope, merge duplicate version checks

* Address review: strict requires, manifest-gated sync, semver inputs, deactivate guard

* Fail closed on undeclared or non-string dependency versions

* Skip seeded-version updates that would rewrite the same value

* Real-method hasDeclaredVersion tests; honest stub comment

* Spellcheck-clean fixture name
2026-11-02 01:05:51 +00:00
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
Adam Daley 4f42dd63be Encrypt stored payment gateway and registrar secrets (#4617)
* Encrypt stored payment gateway and registrar secrets

Store only ciphertext for adapter fields marked secret in
pay_gateway.config and tld_registrar.config, decrypting when an
adapter is built or an admin reads the configuration. Adds a data
migration for existing rows; legacy plaintext rows keep working.

* Tighten SecretConfig docs to codebase style

* Address review: portable migration, concurrent-save guard, unknown adapters, strict ciphertext check

* Fix unrecognized word flagged by spellcheck

* Cover adapter read path with encrypted stored secrets

* Assert non-secret fields survive the registrar read path
2026-11-01 17:13:50 +00:00
renovate[bot]andopencode 90db19da7f Update symfony packages to v7.4.22 (#4602)
* Update symfony packages to v7.4.22

* Tolerate rounded-up retryAfter in rate limiter test

Since symfony/rate-limiter 7.4.22 retryAfter is rounded up (up to one
second late) instead of up to one second early, so a 1-hour fixed
window can report 3601 instead of 3600. Relax the upper bound
accordingly.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: opencode <opencode@localhost>
2026-10-28 22:08:34 +00:00
6527f904b3 Memoize the PBKDF2-derived key in Crypt (#4589)
* Memoize the PBKDF2-derived key in Crypt

getCurrentKey() ran 100,000 PBKDF2 rounds (about 200 ms) on every encrypt and decrypt, so each read of an encrypted setting was expensive. Keep the derived key for the life of the PHP process, keyed by a hash of the passphrase. Nothing is persisted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Harden Crypt key memoization: cap cache, de-flake tests

- Bound the in-memory derived-key memo to 32 entries with FIFO
  eviction so long-lived workers fed many distinct passphrases
  cannot grow memory without bound.
- Add Crypt::clearDerivedKeys() (@internal) so tests reset the memo
  deterministically instead of relying on cross-test baselines.
- Fix potentially flaky wrong-key assertions: a wrong AES-CBC key can
  occasionally decrypt to unrelated garbage that passes the plausibility
  filter, so assert inequality rather than strict false.
- Remove the global cryptDerivedKeys() test helper in favor of a
  closure-scoped reflection lookup.

* Address CodeRabbit review: cache lifetime doc and sentinel test

- Clarify the derived-key memo is request-scoped under PHP-FPM
  (statics reset at request shutdown); CLI and long-lived workers
  reuse it for their whole lifetime. No cross-request cache: out of
  scope.
- Add a sentinel test proving cache hits reuse the stored key: with
  the isset() guard removed the sentinel is overwritten and the test
  fails, while the count-based assertions alone would still pass.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-28 21:24:07 +00:00
Adam Daley dd63eb8dcc Harden TLD fallback: contains-letter final label, no xn-- bypass, absolute end anchor (#4601)
- Drop the str_starts_with('xn--') bypass so punycode suffixes go through
  the same final-label check (rejects xn--p1ai.123, xn--).
- Allow digit-prefixed final labels that contain a letter (co.3abc) while
  still rejecting all-numeric ones (co.123).
- Use \z instead of $ so trailing dots/newlines stay invalid.
- Document intentional choices: no length limits (normalizeTld enforces),
  leading-dot stripping matches the online path (..co.uk behaves the same
  online and offline).
2026-10-28 19:03:39 +00:00
dearpramodandAdam Daley 904e84a50f Accept multi-level suffixes in the TLD validation fallback (#4593)
When the public suffix list cannot be fetched, isTldValid() only accepted plain letters, so suffixes such as com.np or co.uk were rejected even though they validate when the list is available. Accept dot-separated labels of letters, digits and inner hyphens, still requiring a non-numeric last label and rejecting empty or hyphen-edged labels.

Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-28 18:43:03 +00:00
1c1831bca6 Index created_at and purge old activity in batches (#4590)
* Index created_at and purge old activity in batches

The activity retention sweep ran four DELETE ... WHERE created_at <= ? statements on every cron run against columns with no index, so each sweep scanned whole tables, and a large backlog was removed in one statement. Index created_at on the four activity tables (entity metadata for fresh installs and the schema synchronizer, patch 135 for MySQL and MariaDB) and delete in bounded batches that pick up where they left off on the next run. The batches select ids first so the SQL stays portable across the supported databases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* 🎨 Clean up activity retention purge helper

Remove unused DBAL fetch in cleanupOldActivity, drop final from
the public batch-size constant for consistency, and allowlist
purge tables before interpolating into SQL.

* 🚀 Bound the full retention sweep to one deadline and batch size

Share a single 15s deadline across all four activity tables so a large
backlog cannot delay later cron tasks by up to ~60s, and shrink batches
to 500 ids so the expanded IN (?) delete stays within SQLite's
historic 999 host-parameter ceiling.

* 🚀 Order retention batches by the filter column

ORDER BY id could not use the created_at index for ordering, so the
database sorted the whole expired set or scanned by primary key on
every batch. ORDER BY created_at, id is served from the retention
index (covering index on SQLite, implicit PK tail on InnoDB).

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-28 17:57:48 +00:00
7d13d8bd39 Fix the toApiArray auditor crashing on try/catch blocks (#4599)
* Fix the toApiArray auditor crashing on try/catch blocks

The TryCatch branch read $node->try, which php-parser does not have (the
try body is $node->stmts), so the first toApiArray() containing a
try/catch passed null to collectAssignments() and the audit test died
with a TypeError. The catch and finally nodes were passed in whole, and
collectAssignments() has no branch for them, so their bodies were never
examined either.

Walk the try, catch and finally bodies instead. A new test audits a
temporary module whose toApiArray() sets a field conditionally in each
of the three; it fails with the TypeError before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the audit test's temporary directory in plain words

cspell rejects "toapiarray".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-28 11:04:46 +00:00
Adam Daley e5fc354f39 Fix partial WHM provisioning recovery and password persistence (#4584)
* Fix recovery from partial WHM provisioning

* Add upgrade coverage for hosting provisioning state

* Reconcile interrupted WHM account creation before retrying

* Verify WHM account identity before resuming reseller setup
2026-10-26 04:36:34 +00:00
Adam Daley 5783431f08 Fix WHM package exception classification (#4583) 2026-10-26 02:36:22 +00:00
Adam Daley 2c38f075f1 Fix full Unicode support on legacy MySQL/MariaDB databases (#4581)
* Fix Unicode text storage on legacy databases

Repair legacy utf8/utf8mb3 text columns and table defaults while preserving indexed identifiers, column definitions, and unit defaults. Add upgrade regression coverage and an audited exclusion inventory.

Fixes #4579

* Fix spellcheck for Unicode migration fixtures

* Verify fresh Unicode installs on legacy databases

* Normalize legacy UTF-8 database connections
2026-10-26 02:12:28 +00:00
Adam Daley aad11a53e6 Refund Stripe payments when an invoice is refunded (#4567)
* 🐛 Refund Stripe payments when an invoice is refunded

Admin refunds only created an internal credit note, so FOSSBilling
showed refunded while Stripe showed nothing. Issue the refund through
the Stripe API first (PaymentIntents, charges, and subscription
invoices), record an audit transaction, and add a gateway opt-out.

- Add Payment_Adapter_Stripe::refundPayment() and ::supportsRefunds()
  with Stripe minor-unit conversion and idempotency keys
- Record dashboard refunds as credit notes via refund.created webhooks;
  partial ones are left for the admin to record
- Reuse unrecorded Stripe refunds on retry instead of refunding twice
- Add gateway_refund API/option plus admin UI checkboxes

Fixes #4564

* 🐛 Ignore dashboard refund webhooks beaten by a concurrent refund

Both refund paths funnel through refundInvoice's locked status check,
so a duplicate credit note was already impossible. The remaining flaw
was UX: the losing webhook kept an ERROR transaction row for something
that resolved correctly. Re-check the invoice after a recording
failure and silently acknowledge when it is already settled.

* 🐛 Reverse deposit credit before issuing gateway refunds

A staff account with invoice.manage_invoices but without
client.manage_balance could refund a paid Add Funds invoice and return
real card money through Stripe while the wallet credit survived, an
exploitable double-spend. Require the balance permission for gateway
refunds of deposit invoices and debit the wallet credit atomically with
the credit note, failing closed on insufficient balance. Dashboard
refunds recording path reverses the credit the same way.

* Fix tests for gateway refund capability checks

Mockery magic methods are invisible to method_exists, so adapter
mocks must extend the real Payment_Adapter_Stripe class for the
refund-capability checks to behave as in production. Also update
older refund tests for the up-front paid-status guard.

* Fix tests for gateway refund capability checks

* Fix refund test mock counts for the orphan-reuse lookup

* Apply php-cs-fixer to refund tests

* Remove duplication from Stripe refund target resolution

* Make gateway_returned unavailable to API callers

* Fix idempotency key collisions and expand Stripe invoice payments

* Serialize invoice refunds under a per-invoice lock

* Apply Rector switch-to-match in createRefundDocument

* Reverse the deposit credit before calling the gateway

* Give each refund attempt its own idempotency scope

* Rename test variable tripping spellcheck

* Reject dashboard refunds that exceed the remaining amount

* Resolve Stripe refund targets through invoice payments

* Process invoice payments without a status

* Fail loudly when a gateway refund has no refundable payment

* Fail loudly when the gateway adapter cannot load

* Require exact line selection match for refund reuse

* Assert recorded amounts in partial reuse test

* Fix two stale comments left by the refund flow changes
2026-10-22 23:56:35 +01:00
Santiago RodríguezandAdam Daley 08298dc89a Add auto-login support to the CWP server manager (#4459)
* Refactor getLoginUrl and improve error handling

* Replace error_log with getLog()->error

* Fall back to the plain CWP panel URL when SSO is unavailable

The auto-login endpoint is not available on every API key, so a failed
user_session request must not break the login button. Mirror the WHM
manager: log the failure (username only, never tokens or URLs) and
return the standard panel URL.

Use timer 5, matching CWP's own WHMCS module, and cover the new
behaviour with unit tests.

* Reuse the CWP test manager helper for the session request test

* Handle transport failures and non-string session URLs in CWP login

Convert Symfony transport exceptions into Server_Exception so every
CWP request failure funnels through the plain panel URL fallback,
and only accept a non-empty string session URL.

* Bound the CWP session request duration

Limit user_session to 10s idle / 20s total so a hung panel fails fast
to the manual login URL instead of stalling the page load. Other
operations keep the existing client defaults.

---------

Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-22 22:15:18 +01:00
Harshvardhan AgrawalandAdam Daley e54a8367de Fix HTTP status mapping for API errors with explicit HTTP codes (#4266)
* API always returns the 200 status code

Updated ApiResponseFactory::getStatusCode to map standard HTTP error status codes (400-599) to their corresponding HTTP status codes while preserving mappings for internal application codes and legacy OK status for unmapped internal error codes.

* Map colliding app codes (413,436,437,498) to 400, keep envelope code

Prevents HTTP 413 / non-standard 436,437,498 statuses for existing
application errors while still honouring explicit HTTP codes in 400-599.
Adds coverage and removes trailing blank line failing CS fixer.

---------

Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-22 21:02:29 +01:00
Adam Daleyandopencode 40de3ca924 Fix WHM activation failing when package already exists (#4565)
* Fix WHM activation failing when package already exists (#4560)

- Request listpkgs with want=all and parse both legacy package and modern data.pkg shapes
- On addpkg already-exists, sync via editpkg and continue; only block activation on other errors
- Include WHM reason in Server_Exception and fix placeholder keys

* Add WHM API terms to spellcheck dictionary

* Address review: use typed exception for WHM already-exists, keep reasons out of API errors

- Add Server_PackageExistsException; request() now throws generic messages and translates already-exists at the WHM boundary
- checkPackageExists() catches the typed exception instead of matching getMessage()

* Fix Rector findings: remove unused catch variable and setAccessible call

* Handle modern WHM metadata.result failures in Whm::request

---------

Co-authored-by: opencode <opencode@localhost>
2026-10-22 17:03:35 +01:00
Adam Daley 025ebc0850 Harden guest session-state API calls against cross-site requests (#4546)
* Harden guest session-state API calls against cross-site requests

Extend the guest authentication guard to the session-bound cart mutations
(cart add/remove item, promo apply/remove, reset, currency change),
requiring the pre-login session nonce and a same-origin context like
client login, signup, and staff login already do. Cart changes accept GET
with the nonce in the query string, matching _checkCSRFToken()'s token
sources, so shipped fb_api_link usage keeps working.

Also pin an explicit SameSite=Lax on the session cookie when
security.mode is not strict: an unset attribute leaves cookie behavior to
each browser's default, and some browsers send cookies on cross-site
requests when it is missing. The installer session already uses Lax.

* Default queued cookies to SameSite Lax

CookieQueue now defaults to Lax so locale and timezone preference
cookies carry an explicit policy instead of leaving the behavior to
each browser's unset-attribute default. The CSRF cookie keeps its
explicit Strict, and callers can still pass null to omit the
attribute.

* Sign guest E2E requests with the pre-login nonce

The Live Tests failures on the cart guard came from the test helper
keeping its own copy of the guarded-endpoint list (login and signup
only), so guest cart calls went out unsigned and were rejected with
403. Attach the nonce to every guest request instead - the dispatcher
strips the parameter where it is not used - and let guest GET requests
carry it in the query string, which _checkCSRFToken() already accepts.

* Set the cURL URL after appending guest GET parameters

CURLOPT_URL copies the value at set time, so mutating $url after
curl_setopt_array() left the handle pointing at the query-less URL and
guest GET requests went out without the nonce or parameters. Re-set
CURLOPT_URL once the query string is appended.
2026-10-18 03:05:01 +01:00
Adam Daley 8c50583a91 Document UGX exclusion and hardcode Stripe decimal lists (#4542)
* Address review comments on #4541: document UGX exclusion, hardcode Stripe decimal lists

- Rename STRIPE_ZERO_DECIMAL_CURRENCIES to STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES
  and document why UGX is excluded (zero-decimal per Stripe's list, but its
  special-case row requires charge amounts x100), plus ISK/HUF/TWD notes.
- Replace the CLDR-based three-decimal check with a hardcoded
  STRIPE_THREE_DECIMAL_CURRENCIES list (BHD, JOD, KWD, OMR, TND); drop the
  Symfony Intl dependency from this helper.
- Reword the StripeTest comment to state the UGX exception explicitly.

* Extend Stripe currency test coverage in #4541 follow-up

- getAmountInMinorUnits: add BHD (second three-decimal code), AFN/YER
  (more CLDR-zero codes Stripe charges x100), TWD (payout-zero/charge-two
  special case), unknown-code fallback (XXX) and lowercase inputs.
- Add getAmountFromMinorUnits round-trip test via invokePrivateMethod so the
  read-back path is covered too.
2026-10-17 23:04:22 +01:00
terminalstate 304b731f4e Send Stripe amounts in Stripe's units for COP, HUF, IDR, ISK, PKR, UGX and other currencies (#4541)
* 🐛 Send Stripe amounts in Stripe's units, not CLDR's

The adapter scaled amounts by CLDR's fraction digits, which are 0 for
COP, HUF, IDR, ISK, PKR, UGX and several other currencies that Stripe
expects with two decimals, so it sent 1/100 of the invoice total and
read Stripe's amounts back with the same factor. Use Stripe's rules:
its zero-decimal currencies as-is, three-decimal ones with three
digits, everything else (ISK and UGX included) x100.

* ✏️ Avoid an unknown word in the Stripe currency test comment
2026-10-17 22:47:04 +01:00
Adam Daley db0c3dedf2 Harden Stripe renewal paid-skip for inconclusive billing reasons (#4536)
* Harden Stripe renewal paid-skip for inconclusive billing reasons

Only skip the already-paid original invoice for the initial payment or
when billing_reason is missing (ambiguous: duplicate initial webhook vs
renewal — skipping avoids double-crediting). Adds regression tests that
a duplicate initial webhook is still skipped and documents the double
DB fetch as an identity-map hit.

* Rename test identifiers to satisfy spellcheck
2026-10-16 02:11:04 +01:00
terminalstateandAdam Daley 7058313b07 Record Stripe renewal payments after the first invoice is paid (#4523)
🐛 Record Stripe renewal payments after the first invoice is paid

The subscription metadata always points at the first invoice of the
subscription, so the "already paid" skip in invoice.paid handling
returned early for every renewal: no funds were added and the renewal
invoice stayed unpaid. Apply the skip to the initial payment only.

Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
2026-10-16 01:10:59 +01:00
Adam Daley 1a469d09cf Fix cron-sent emails rendering dates in en_US instead of configured locale (#4535)
* Fix cron-sent emails rendering dates in en_US instead of configured locale

In CLI/cron context there is no Accept-Language header, so
Locale::acceptFromHttp() fails and Locale::lookup() with an empty
detected locale returns the first enabled locale (en_US) instead of
null, overriding the configured i18n.locale default. Guard
getBrowserLocale() against empty/unparsable headers so it falls back
to the configured default, and stop queueing empty locale cookies.

Also plumb an explicit per-recipient locale through email rendering:
TwigFactory::createEmailEnvironment(), System render helpers, and
Email::sendTemplate() now honor the recipient's language
(client lang, per staff member on broadcasts), outranking browser
auto-detection, with Massmailer rendering per-client too.

Fixes #4524

* Fix tests: simulate header-less requests, update subject render expectation

Symfony's Request::create() seeds a default Accept-Language header,
so strip it to reproduce the CLI/cron condition in the new
getActiveLocale fallback tests. Update the existing
renderEmailSubjectString test for the new locale parameter.
2026-10-16 00:02:51 +01:00
Adam Daley a49cf8fd5f Soften PayPal item_number echo check to a warning (#4534)
🐛 Soften PayPal item_number echo check to a warning

PayPal freezes item_number at subscription signup while the invoice
number can change (renumbering, issuing), so the strict check added in
#4424 failed legitimate renewal payments for older subscriptions.

Downgrade the item_number mismatch to a warning log and keep
processing; the stable internal invoice id reference remains a hard
error. The signed callback binding, subscription linkage, and amount
checks remain the payment binding.

Fixes #4533
2026-10-15 22:48:46 +01:00
Adam Daley 6b18ba6a23 Refresh default branding and migrate legacy asset paths (#4527)
* Refresh default branding assets

Add updated light and dark logos plus an SVG favicon, and point installer and default company branding settings to the new assets. Update shared theme tests to use the renamed light logo.

* Complete default branding asset migration

* Preserve branding assets still referenced by settings
2026-10-14 20:26:19 +01:00
Adam Daley 5489acb148 Allow custom payment gateway images (#4519)
* Add custom payment gateway images

Allow image uploads and URL imports with AVIF and PNG browser fallback. Preserve sanitized SVG artwork and keep custom images outside packaged assets.

* Fix gateway image CI checks and upgrade coverage

* Limit gateway raster images to four million pixels
2026-10-12 18:23:10 +01:00
Adam Daley bd7e9eab30 Normalize HTTP bootstrap query overrides (#4495) 2026-10-06 23:22:25 +01:00
Adam Daley f5aac2ac36 Serve stale extension directory data on refresh failures (#4493)
* Harden extension directory partial against sparse metadata

Remote directory entries may omit icon_url, author, author URL/id,
releases, or description. Under strict_variables any missing key
crashes the whole Extensions page (and the registrar/payment-gateway
tabs that reuse this partial). Default every optional field and fall
back to a generic icon/author label.

Adds sparse-metadata cases to ExtensionDirectoryTest covering
omitted/null icons, registrar author shapes without URL, missing
authors, and empty/missing releases.

Fixes #4486

* Use page-level type fallback for extension install links

Addresses review: entries without extension.type should stay in the
currently selected directory category instead of falling back to a
hard-coded mod.

* Serve stale extension directory data on refresh failures

The Extensions page blocks on a live directory fetch. Until now a
failed refresh meant every admin page load paid another HTTP timeout
(up to 5s each), and the cache key ignored the installed FOSSBilling
version, so an upgrade could briefly serve the previous version's
filtered list.

Keep a week-long last-known-good copy alongside the hourly entry:
failed refreshes fall back to it (with a warning logged), and
further attempts are suppressed for 5 minutes. The installed
version is now part of the cache key. Non-app exceptions from the
HTTP layer are normalized to FOSSBilling exceptions so callers can
rely on a single exception contract.

* Treat corrupted fresh directory cache entries as misses

* Validate directory results before writing either cache slot

* Validate stale directory results before serving them
2026-10-06 23:06:05 +01:00
Adam Daley eb95b72f0f Respect Formbuilder permissions on product settings (#4494) 2026-10-06 22:48:48 +01:00
Adam Daley 87200f9bf1 Harden extension directory partial against sparse metadata (#4491)
* Harden extension directory partial against sparse metadata

Remote directory entries may omit icon_url, author, author URL/id,
releases, or description. Under strict_variables any missing key
crashes the whole Extensions page (and the registrar/payment-gateway
tabs that reuse this partial). Default every optional field and fall
back to a generic icon/author label.

Adds sparse-metadata cases to ExtensionDirectoryTest covering
omitted/null icons, registrar author shapes without URL, missing
authors, and empty/missing releases.

Fixes #4486

* Use page-level type fallback for extension install links

Addresses review: entries without extension.type should stay in the
currently selected directory category instead of falling back to a
hard-coded mod.
2026-10-06 21:51:22 +01:00
Adam Daley 95243bf71d Keep DirectAdmin POST fields out of request URLs (#4490) 2026-10-06 21:21:53 +01:00
Adam Daley 30faee0c64 Fix CWP suspension error message placeholders (#4487) 2026-10-06 20:38:11 +01:00
Adam Daley c690a0dc5b Handle missing extension directory icons (#4485)
* Handle missing extension directory icons

* Make extension directory test helper readonly
2026-10-06 18:20:31 +01:00
Adam Daley 3d0410bdfe Handle missing Internet.bs registrant fields (#4483) 2026-10-06 15:15:00 +01:00
Adam Daley 3c3e6951b6 Normalize phone numbers before registrar requests (#4482) 2026-10-06 12:25:55 +01:00
Adam Daley cd7b65b04f Harden security boundaries across auth, payments, and extensions (#4474)
* Harden theme settings auth and CSRF checks

* Secure JSON webhook callback validation

Adds pre-persistence validation for JSON payment callbacks before any transaction lifecycle work or storage. This change bounds callback input in the IPN entrypoint, introduces a JsonWebhookAuthenticatorInterface, and validates Stripe webhook signatures through a reusable method before processing. It rejects unsupported/invalid gateways and oversized or malformed payloads, and adds unit coverage for missing, invalid, expired, malformed, oversized, and valid signed callbacks.

* Harden payment return flow

Route gateway return and cancel URLs through a dedicated `/invoice/payment-return` page that only redirects to fixed invoice destinations. This removes the legacy session restore token flow, avoids exposing session capability in payment handoffs, switches PayPal returns to browser redirects (`rm=1`), and adds unit coverage for the new payment return and gateway URL behavior.

* Harden admin transaction error rendering

Replaced inline `onclick` handling in the invoice transactions template with escaped `data-transaction-error*` attributes, and added a delegated click listener in `fossbilling.ts` to show messages safely. This removes executable inline attributes while preserving diagnostic text.

Added unit coverage for both Twig and admin JS to ensure hostile payloads stay as plain text, no script/image injection is introduced, and dynamically inserted error links still behave correctly.

* Harden email confirmation token lifecycle

Refactors client email confirmation to be transactional and replay-safe by binding tokens to the current email hash, enforcing expiry, and atomically consuming/revoking tokens during approval. Email changes from both admin and profile flows now revoke outstanding confirmation links and reset `email_approved` consistently, including concurrent update scenarios.

Adds a dedicated `EmailConfirmationTest` suite covering replay prevention, stale/legacy/expired token rejection, resend behavior, rollback safety, and profile-change edge cases, while removing outdated unit tests that no longer match the new confirmation flow.

* Harden invoice PDF logo fetching

Invoice PDF generation no longer allows Dompdf to reach remote network sources. Logos are now fetched through a no-private-network client, validated for MIME, size, and safe SVG content, and embedded as data URIs when allowed. Malformed, private, redirecting, or unsafe logo URLs are ignored without interrupting invoice rendering, and the new unit tests cover the security and compatibility cases.

* Harden guest login/signup CSRF checks

Adds a dedicated guard for guest `client/login` and `client/create` API calls to require POST, validate the pre-login session CSRF nonce, and block cross-site origins/fetch contexts before dispatch. Client-facing login/signup templates now include `CSRFToken` hidden inputs, and test helpers (PHP + Playwright) fetch `/login` first to reuse the anonymous session token. Unit tests were expanded to cover accepted/rejected authentication request paths and token propagation in shipped forms.

* Avoid persisting extension config on reads

Stop `Extension` config lookups from creating empty `ExtensionMeta` rows, and gate guest settings to core or active modules before reading config. Tests were updated to cover the non-writing read path and to ensure authorized config writes still preserve public settings.

* Harden extension archive extraction

Improve `downloadAndExtract()` security and cleanup behavior by staging extraction in a private temp directory, using cryptographically secure temp names, validating all ZIP entries before extraction (path, null-byte/colon checks, and allowed file types), and removing staged artifacts on failure. After successful validation/extraction, permissions are restored before moving to the final extension path.

Adds unit tests covering rejection of unsafe archive entries before any write occurs and confirming valid package files/directories still extract correctly.

* Harden extension directory data handling

Adds strict validation for extension IDs and catalog metadata in `ExtensionManager`, including cached responses, and rejects unsafe author URLs/schemes. Updates the admin extensions template to remove inline `onclick` usage and load README content via modal event data attributes, preventing injected names from becoming executable script. Includes new unit tests covering malformed directory payloads, cached validation behavior, safe rendering of quoted names, identifier pre-validation, and valid metadata edge cases.

* Harden client password reset revocation

Move client password changes and reset issuance into `ClientPasswordResetRepository` so password updates and reset-token revocation happen atomically. Admin, guest, and profile password changes now reuse the same transactional path, preventing stale recovery links from overwriting newer passwords and replacing outstanding reset tokens safely across supported drivers. Added unit coverage for revocation, rollback, stale-token rejection, and driver-specific locking behavior.

* Harden staff password reset transactions

Refactors admin password changes to go through `AdminPasswordResetRepository` so password updates and reset-token revocation happen atomically in one transaction. Profile and staff password-change flows, plus guest reset redemption/request flows, now reuse repository methods (`changePassword`/`replaceReset`) instead of manually persisting/removing entities.

Adds row-lock based eligibility rechecks (including SQLite-specific lock handling) to prevent stale or preloaded reset tokens from being reused across concurrent updates. Unit tests were updated and expanded with a new repository test suite covering revocation behavior, expiry/eligibility checks, rollback safety, side-effect failures, and concurrency races.

* Prevent public installer reinstall when debug is enabled

* Fix strict-variables signup test context for CSRFToken

* Fix PHP 8.5 SVG logo detection and Rector findings

Route image/svg+xml reported by getimagesizefromstring to the self-contained SVG validator instead of the raster path. Apply ReturnNeverTypeRector and ReadOnlyAnonymousClassRector suggestions in tests.

* Satisfy PHPStan for SVG mime check in PDF logo fetch

* Add nosniff and pjpeg to spellcheck dictionary

* Reuse CSRF cookie for pre-login API tests

Update the test API helper to read the pre-login CSRF token from the cookie jar before falling back to parsing the login page. This keeps signup and other back-to-back guest requests on the same session, even when `/login` redirects or the session id rotates.

* Send CSRF token in Playwright stale-login check

The password-change test asserts stale credentials are rejected with an invalid-credentials message. Since guest logins now require the pre-login session nonce, submit the double-submit cookie value like the real login form does; otherwise the request is rejected before credentials are checked. This also avoids the retry that pushed the suite over the client_signup rate limit.

* Address review findings: rate-limit ordering, staging cleanup, README race

- Run guest CSRF validation after the rate limiter so invalid-token floods still consume api_login/api_guest budgets.\n- Remove extension staging dir and archive when destination resolution, the already-installed check, or the final move fails.\n- Ignore stale extension README responses that arrive after another extension was selected.

* Check stale passwords from a fresh session in Playwright

Changing a password invalidates every session including the current one, so the old session can no longer pass CSRF validation. Run the stale-password login from a fresh anonymous context with its own pre-login nonce, like a real login attempt.
2026-10-05 00:08:12 +01:00
Adam Daley a7a219e5c9 Preserve order configuration when editing form answers (#4472) 2026-10-04 20:50:43 +01:00
Adam Daley cac9e8b383 Validate missing Hestia hostnames before building API URLs (#4470)
Validate Hestia hostname before using the server manager
2026-10-04 19:00:12 +01:00
Adam Daley 2ae683de46 Validate invoice counters and check existing settings during upgrades (#4468)
* Validate invoice counters and warn about invalid upgrade settings

* Remove redundant cast from invoice counter overflow test
2026-10-04 17:58:58 +01:00
Adam Daley 83a676f5d9 Fix Unicode storage in custom pages and MySQL defaults (#4467)
* Support full Unicode in custom pages and MySQL defaults

* Recognize charset migration terms in spellcheck

* Keep charset repair guidance in the pull request
2026-10-04 17:29:40 +01:00
Adam Daley c3b5631e7d Handle invalid CWP responses and malformed PayPal IPNs (#4464)
* Handle invalid CWP responses and malformed PayPal IPNs

Reject incomplete CWP account details before synchronization and explain empty or invalid JSON responses. Reject array-valued PayPal IPN fields before verification.

Add regression coverage for failed responses, unchanged accounts and valid synchronization. Addresses FOSSBILLING-NN9, FOSSBILLING-CMA, FOSSBILLING-NJD, FOSSBILLING-JEW and FOSSBILLING-QMT.

* Report CWP HTTP failures separately from invalid JSON
2026-10-04 16:25:04 +01:00
Adam Daley 71b4d08300 Route offline payments through explicit admin approval (#4453)
* ✨ Route offline payments through explicit admin approval

Replace the fragile stored-IPN check for manual gateways with a
capability split: automated adapters verify callbacks, offline gateways
settle through a permission-checked approve action and state machine.
Centralize IPN decoding so typed adapter signatures never see null, give
adapters explicit error codes, and remove the dead validate_ipn flag.

* Satisfy Rector: drop redundant casts, readonly test double

* Make approval E2E immune to IPN-hash dedupe collisions

* Reword E2E variable flagged by spellcheck
2026-10-03 06:10:42 +01:00
Adam Daley 5372e6b4ea Fix client group migration on PostgreSQL and SQLite (#4450) 2026-10-03 01:22:37 +01:00
Adam Daley 130046b5d5 Fix stale DDEV autoloader check and patch125 client group FK migration (#4446) 2026-10-02 17:34:52 +01:00
Adam Daley 50c379eeb1 Fix PayPal payments stuck on processing from double claim (#4437) 2026-10-01 13:42:05 +01:00
Adam Daley a83fc69e7a 🐛 Heal promo schema drift behind #4386/#4401 and surface sync failures (#4436)
* 🐛 Heal promo schema drift behind #4386/#4401 and surface sync failures

Add patch127 with the promo columns (requires_products, auto_apply,
priority, stackable) plus the auto_apply index that #4386 and #4401
shipped without a migration, crashing promo loads with "Unknown
column 't0.requires_products'" on installs the portable sync didn't
heal. Surface the drift state and last failed sync in the
finalization status (with a finalize-screen warning) so a repeatedly
failing sync is visible instead of living only in the update log.

Fixes #4433

* 📝 Allow LONGTEXT in spellcheck dictionary

Required by patch127's promo migration DDL.

* 🐛 Report schema drift independently of the sync retry cooldown

isSchemaOutOfSync() honors the cooldown, which hid the drift status
(and the finalize-screen warning) for an hour after each failed
sync. Status now uses a cooldown-ignoring hash check, and the warning
stays visible once finalized with drift remaining.
2026-10-01 12:36:04 +01:00
Adam Daley 668fb8c687 Merge renewal invoices sharing a client, currency, due date, and period (#4432)
* Merge renewal invoices sharing a client, currency, due date, and period

Adds an opt-in renewal merge for the batch generator (#4118): orders
sharing a client, currency, due date, and billing period go onto one
invoice instead of one invoice per order. Precedence is per-order meta
override, then the client's tri-state preference, then the global
invoice_merge_renewals setting (off by default). Orders paid through
an active gateway subscription keep their one-invoice shape.

Grouping happens upstream of generation, so every order is invoiced
exactly once and stamped with the shared invoice id (no #1494/#1969
repeat). Each merged invoice is a fresh draft, so invoice immutability
is never violated.

* Deslop the renewal-merge diff

* Address review: boolean normalization, atomic merged invoices, settings off-switch, E2E flag restore

* Retry bucket orders singly when merged generation fails, issuance outside generation

* Reset the entity manager and stop the batch when merged generation closes it
2026-09-29 20:52:14 +01:00
Adam Daley 256751b831 Allow clients to belong to multiple client groups (#4405)
* Allow clients to belong to multiple client groups (#4387)

* Use spellcheck-clean identifiers in multi-group E2E

* Renumber multi-group migration to patch125 after main merge

* Halve guest logins in multi-group E2E for the shared api_login budget

* Only migrate group assignments that reference existing groups
2026-09-29 18:58:44 +01:00
Adam Daley c76530ee68 Fix spellcheck failures on main (#4427) 2026-09-29 02:56:06 +01:00
Adam Daley 8e47d9c89b Fix Sentry-reported runtime errors and filter third-party adapter noise (#4426) 2026-09-29 01:57:41 +01:00
Adam Daley 3a3ec8bcd2 Heal stuck update finalization after stale-code patch runs (#4425)
Reset the PHP opcode cache after extracting the update archive, so the next request's automatic finalization cannot run against pre-update classes and stamp the state finalized without applying patches.

Also heal a finalized state that still reports pending patches on the next request, keep offering finalization on the finalize screen until the patch count reaches zero, and report the database/code patch levels in the completion guard's error.
2026-09-28 22:53:21 +01:00